Skip to content

Commit

Permalink
expression: support builtin function json_contains (#7443)
Browse files Browse the repository at this point in the history
  • Loading branch information
mccxj authored and zz-jason committed Sep 7, 2018
1 parent b2bfd8f commit 3f5bd3b
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 14 deletions.
21 changes: 11 additions & 10 deletions expression/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,14 +572,15 @@ var funcs = map[string]functionClass{
ast.ValidatePasswordStrength: &validatePasswordStrengthFunctionClass{baseFunctionClass{ast.ValidatePasswordStrength, 1, 1}},

// json functions
ast.JSONType: &jsonTypeFunctionClass{baseFunctionClass{ast.JSONType, 1, 1}},
ast.JSONExtract: &jsonExtractFunctionClass{baseFunctionClass{ast.JSONExtract, 2, -1}},
ast.JSONUnquote: &jsonUnquoteFunctionClass{baseFunctionClass{ast.JSONUnquote, 1, 1}},
ast.JSONSet: &jsonSetFunctionClass{baseFunctionClass{ast.JSONSet, 3, -1}},
ast.JSONInsert: &jsonInsertFunctionClass{baseFunctionClass{ast.JSONInsert, 3, -1}},
ast.JSONReplace: &jsonReplaceFunctionClass{baseFunctionClass{ast.JSONReplace, 3, -1}},
ast.JSONRemove: &jsonRemoveFunctionClass{baseFunctionClass{ast.JSONRemove, 2, -1}},
ast.JSONMerge: &jsonMergeFunctionClass{baseFunctionClass{ast.JSONMerge, 2, -1}},
ast.JSONObject: &jsonObjectFunctionClass{baseFunctionClass{ast.JSONObject, 0, -1}},
ast.JSONArray: &jsonArrayFunctionClass{baseFunctionClass{ast.JSONArray, 0, -1}},
ast.JSONType: &jsonTypeFunctionClass{baseFunctionClass{ast.JSONType, 1, 1}},
ast.JSONExtract: &jsonExtractFunctionClass{baseFunctionClass{ast.JSONExtract, 2, -1}},
ast.JSONUnquote: &jsonUnquoteFunctionClass{baseFunctionClass{ast.JSONUnquote, 1, 1}},
ast.JSONSet: &jsonSetFunctionClass{baseFunctionClass{ast.JSONSet, 3, -1}},
ast.JSONInsert: &jsonInsertFunctionClass{baseFunctionClass{ast.JSONInsert, 3, -1}},
ast.JSONReplace: &jsonReplaceFunctionClass{baseFunctionClass{ast.JSONReplace, 3, -1}},
ast.JSONRemove: &jsonRemoveFunctionClass{baseFunctionClass{ast.JSONRemove, 2, -1}},
ast.JSONMerge: &jsonMergeFunctionClass{baseFunctionClass{ast.JSONMerge, 2, -1}},
ast.JSONObject: &jsonObjectFunctionClass{baseFunctionClass{ast.JSONObject, 0, -1}},
ast.JSONArray: &jsonArrayFunctionClass{baseFunctionClass{ast.JSONArray, 0, -1}},
ast.JSONContains: &jsonContainsFunctionClass{baseFunctionClass{ast.JSONContains, 2, 3}},
}
66 changes: 66 additions & 0 deletions expression/builtin_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var (
_ functionClass = &jsonMergeFunctionClass{}
_ functionClass = &jsonObjectFunctionClass{}
_ functionClass = &jsonArrayFunctionClass{}
_ functionClass = &jsonContainsFunctionClass{}

// Type of JSON value.
_ builtinFunc = &builtinJSONTypeSig{}
Expand All @@ -56,6 +57,8 @@ var (
_ builtinFunc = &builtinJSONRemoveSig{}
// Merge JSON documents, preserving duplicate keys.
_ builtinFunc = &builtinJSONMergeSig{}
// Check JSON document contains specific target.
_ builtinFunc = &builtinJSONContainsSig{}
)

type jsonTypeFunctionClass struct {
Expand Down Expand Up @@ -548,3 +551,66 @@ func jsonModify(ctx sessionctx.Context, args []Expression, row chunk.Row, mt jso
}
return res, false, nil
}

type jsonContainsFunctionClass struct {
baseFunctionClass
}

type builtinJSONContainsSig struct {
baseBuiltinFunc
}

func (b *builtinJSONContainsSig) Clone() builtinFunc {
newSig := &builtinJSONContainsSig{}
newSig.cloneFrom(&b.baseBuiltinFunc)
return newSig
}

func (c *jsonContainsFunctionClass) getFunction(ctx sessionctx.Context, args []Expression) (builtinFunc, error) {
if err := c.verifyArgs(args); err != nil {
return nil, errors.Trace(err)
}
argTps := []types.EvalType{types.ETJson, types.ETJson}
if len(args) == 3 {
argTps = append(argTps, types.ETString)
}
bf := newBaseBuiltinFuncWithTp(ctx, args, types.ETInt, argTps...)
sig := &builtinJSONContainsSig{bf}
sig.setPbCode(tipb.ScalarFuncSig_JsonContainsSig)
return sig, nil
}

func (b *builtinJSONContainsSig) evalInt(row chunk.Row) (res int64, isNull bool, err error) {
obj, isNull, err := b.args[0].EvalJSON(b.ctx, row)
if isNull || err != nil {
return res, isNull, errors.Trace(err)
}
target, isNull, err := b.args[1].EvalJSON(b.ctx, row)
if isNull || err != nil {
return res, isNull, errors.Trace(err)
}
var pathExpr json.PathExpression
if len(b.args) == 3 {
path, isNull, err := b.args[2].EvalString(b.ctx, row)
if isNull || err != nil {
return res, isNull, errors.Trace(err)
}
pathExpr, err = json.ParseJSONPathExpr(path)
if err != nil {
return res, true, errors.Trace(err)
}
if pathExpr.ContainsAnyAsterisk() {
return res, true, json.ErrInvalidJSONPathWildcard
}
var exists bool
obj, exists = obj.Extract([]json.PathExpression{pathExpr})
if !exists {
return res, true, nil
}
}

if json.ContainsBinary(obj, target) {
return 1, false, nil
}
return 0, false, nil
}
65 changes: 64 additions & 1 deletion expression/builtin_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func (s *testEvaluatorSuite) TestJSONObject(c *C) {
}
}

func (s *testEvaluatorSuite) TestJSONORemove(c *C) {
func (s *testEvaluatorSuite) TestJSONRemove(c *C) {
defer testleak.AfterTest(c)()
fc := funcs[ast.JSONRemove]
tbl := []struct {
Expand Down Expand Up @@ -308,3 +308,66 @@ func (s *testEvaluatorSuite) TestJSONORemove(c *C) {
}
}
}

func (s *testEvaluatorSuite) TestJSONContains(c *C) {
defer testleak.AfterTest(c)()
fc := funcs[ast.JSONContains]
tbl := []struct {
input []interface{}
expected interface{}
success bool
}{
// Tests nil arguments
{[]interface{}{nil, `1`, "$.c"}, nil, true},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, nil, "$.a[3]"}, nil, true},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, nil}, nil, true},
// Tests with path expression
{[]interface{}{`[1,2,[1,[5,[3]]]]`, `[1,3]`, "$[2]"}, 1, true},
{[]interface{}{`[1,2,[1,[5,{"a":[2,3]}]]]`, `[1,{"a":[3]}]`, "$[2]"}, 1, true},
{[]interface{}{`[{"a":1}]`, `{"a":1}`, "$"}, 1, true},
{[]interface{}{`[{"a":1,"b":2}]`, `{"a":1,"b":2}`, "$"}, 1, true},
{[]interface{}{`[{"a":{"a":1},"b":2}]`, `{"a":1}`, "$.a"}, 0, true},
// Tests without path expression
{[]interface{}{`{}`, `{}`}, 1, true},
{[]interface{}{`{"a":1}`, `{}`}, 1, true},
{[]interface{}{`{"a":1}`, `1`}, 0, true},
{[]interface{}{`{"a":[1]}`, `[1]`}, 0, true},
{[]interface{}{`{"b":2, "c":3}`, `{"c":3}`}, 1, true},
{[]interface{}{`1`, `1`}, 1, true},
{[]interface{}{`[1]`, `1`}, 1, true},
{[]interface{}{`[1,2]`, `[1]`}, 1, true},
{[]interface{}{`[1,2]`, `[1,3]`}, 0, true},
{[]interface{}{`[1,2]`, `["1"]`}, 0, true},
{[]interface{}{`[1,2,[1,3]]`, `[1,3]`}, 1, true},
{[]interface{}{`[1,2,[1,[5,[3]]]]`, `[1,3]`}, 1, true},
{[]interface{}{`[1,2,[1,[5,{"a":[2,3]}]]]`, `[1,{"a":[3]}]`}, 1, true},
{[]interface{}{`[{"a":1}]`, `{"a":1}`}, 1, true},
{[]interface{}{`[{"a":1,"b":2}]`, `{"a":1}`}, 1, true},
{[]interface{}{`[{"a":{"a":1},"b":2}]`, `{"a":1}`}, 0, true},
// Tests path expression contains any asterisk
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$.*"}, nil, false},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$[*]"}, nil, false},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$**.a"}, nil, false},
// Tests path expression does not identify a section of the target document
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$.c"}, nil, true},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$.a[3]"}, nil, true},
{[]interface{}{`{"a": [1, 2, {"aa": "xx"}]}`, `1`, "$.a[2].b"}, nil, true},
}
for _, t := range tbl {
args := types.MakeDatums(t.input...)
f, err := fc.getFunction(s.ctx, s.datumsToConstants(args))
c.Assert(err, IsNil)
d, err := evalBuiltinFunc(f, chunk.Row{})

if t.success {
c.Assert(err, IsNil)
if t.expected == nil {
c.Assert(d.IsNull(), IsTrue)
} else {
c.Assert(d.GetInt64(), Equals, int64(t.expected.(int)))
}
} else {
c.Assert(err, NotNil)
}
}
}
2 changes: 2 additions & 0 deletions expression/distsql_builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ func getSignatureByPB(ctx sessionctx.Context, sigCode tipb.ScalarFuncSig, tp *ti
f = &builtinJSONRemoveSig{base}
case tipb.ScalarFuncSig_JsonMergeSig:
f = &builtinJSONMergeSig{base}
case tipb.ScalarFuncSig_JsonContainsSig:
f = &builtinJSONContainsSig{base}
case tipb.ScalarFuncSig_LikeSig:
f = &builtinLikeSig{base}

Expand Down
13 changes: 13 additions & 0 deletions expression/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3205,6 +3205,19 @@ func (s *testIntegrationSuite) TestFuncJSON(c *C) {
tk.MustExec(`update table_json set a=json_set(a,'$.a',json_object('a',1,'b',2)) where json_extract(a,'$.a[1]') = '2'`)
r = tk.MustQuery(`select json_extract(a, '$.a.a'), json_extract(a, '$.a.b') from table_json`)
r.Check(testkit.Rows("1 2", "<nil> <nil>"))

r = tk.MustQuery(`select json_contains(NULL, '1'), json_contains('1', NULL), json_contains('1', '1', NULL)`)
r.Check(testkit.Rows("<nil> <nil> <nil>"))
r = tk.MustQuery(`select json_contains('{}','{}'), json_contains('[1]','1'), json_contains('[1]','"1"'), json_contains('[1,2,[1,[5,[3]]]]', '[1,3]', '$[2]'), json_contains('[1,2,[1,[5,{"a":[2,3]}]]]', '[1,{"a":[3]}]', "$[2]"), json_contains('{"a":1}', '{"a":1,"b":2}', "$")`)
r.Check(testkit.Rows("1 1 0 1 1 0"))
r = tk.MustQuery(`select json_contains('{"a": 1}', '1', "$.c"), json_contains('{"a": [1, 2]}', '1', "$.a[2]"), json_contains('{"a": [1, {"a": 1}]}', '1', "$.a[1].b")`)
r.Check(testkit.Rows("<nil> <nil> <nil>"))
rs, err := tk.Exec("select json_contains('1','1','$.*')")
c.Assert(err, IsNil)
c.Assert(rs, NotNil)
_, err = session.GetRows4Test(context.Background(), tk.Se, rs)
c.Assert(err, NotNil)
c.Assert(err.Error(), Equals, "[json:3149]In this situation, path expressions may not contain the * and ** tokens.")
}

func (s *testIntegrationSuite) TestColumnInfoModified(c *C) {
Expand Down
1 change: 1 addition & 0 deletions mysql/errcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@ const (
ErrInvalidJSONText = 3140
ErrInvalidJSONPath = 3143
ErrInvalidJSONData = 3146
ErrInvalidJSONPathWildcard = 3149
ErrJSONUsedAsKey = 3152

// TiDB self-defined errors.
Expand Down
1 change: 1 addition & 0 deletions mysql/errname.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ var MySQLErrName = map[uint16]string{
ErrInvalidJSONText: "Invalid JSON text: %-.192s",
ErrInvalidJSONPath: "Invalid JSON path expression %s.",
ErrInvalidJSONData: "Invalid data type for JSON data",
ErrInvalidJSONPathWildcard: "In this situation, path expressions may not contain the * and ** tokens.",
ErrJSONUsedAsKey: "JSON column '%-.192s' cannot be used in key specification.",

// TiDB errors.
Expand Down
1 change: 1 addition & 0 deletions mysql/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,5 +253,6 @@ var MySQLState = map[uint16]string{
ErrInvalidJSONText: "22032",
ErrInvalidJSONPath: "42000",
ErrInvalidJSONData: "22032",
ErrInvalidJSONPathWildcard: "42000",
ErrJSONUsedAsKey: "42000",
}
42 changes: 42 additions & 0 deletions types/json/binary_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,3 +689,45 @@ func PeekBytesAsJSON(b []byte) (n int, err error) {
err = errors.New("Invalid JSON bytes")
return
}

// ContainsBinary check whether JSON document contains specific target according the following rules:
// 1) object contains a target object if and only if every key is contained in source object and the value associated with the target key is contained in the value associated with the source key;
// 2) array contains a target nonarray if and only if the target is contained in some element of the array;
// 3) array contains a target array if and only if every element is contained in some element of the array;
// 4) scalar contains a target scalar if and only if they are comparable and are equal;
func ContainsBinary(obj, target BinaryJSON) bool {
switch obj.TypeCode {
case TypeCodeObject:
if target.TypeCode == TypeCodeObject {
len := target.getElemCount()
for i := 0; i < len; i++ {
key := target.objectGetKey(i)
val := target.objectGetVal(i)
if exp, exists := obj.objectSearchKey(key); !exists || !ContainsBinary(exp, val) {
return false
}
}
return true
}
return false
case TypeCodeArray:
if target.TypeCode == TypeCodeArray {
len := target.getElemCount()
for i := 0; i < len; i++ {
if !ContainsBinary(obj, target.arrayGetElem(i)) {
return false
}
}
return true
}
len := obj.getElemCount()
for i := 0; i < len; i++ {
if ContainsBinary(obj.arrayGetElem(i), target) {
return true
}
}
return false
default:
return CompareBinary(obj, target) == 0
}
}
31 changes: 31 additions & 0 deletions types/json/binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,34 @@ func BenchmarkBinaryMarshal(b *testing.B) {
bj.MarshalJSON()
}
}

func (s *testJSONSuite) TestBinaryJSONContains(c *C) {
var tests = []struct {
input string
target string
expected bool
}{
{`{}`, `{}`, true},
{`{"a":1}`, `{}`, true},
{`{"a":1}`, `1`, false},
{`{"a":[1]}`, `[1]`, false},
{`{"b":2, "c":3}`, `{"c":3}`, true},
{`1`, `1`, true},
{`[1]`, `1`, true},
{`[1,2]`, `[1]`, true},
{`[1,2]`, `[1,3]`, false},
{`[1,2]`, `["1"]`, false},
{`[1,2,[1,3]]`, `[1,3]`, true},
{`[1,2,[1,[5,[3]]]]`, `[1,3]`, true},
{`[1,2,[1,[5,{"a":[2,3]}]]]`, `[1,{"a":[3]}]`, true},
{`[{"a":1}]`, `{"a":1}`, true},
{`[{"a":1,"b":2}]`, `{"a":1}`, true},
{`[{"a":{"a":1},"b":2}]`, `{"a":1}`, false},
}

for _, tt := range tests {
obj := mustParseBinaryFromString(c, tt.input)
target := mustParseBinaryFromString(c, tt.target)
c.Assert(ContainsBinary(obj, target), Equals, tt.expected)
}
}
9 changes: 6 additions & 3 deletions types/json/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,15 @@ var (
ErrInvalidJSONPath = terror.ClassJSON.New(mysql.ErrInvalidJSONPath, mysql.MySQLErrName[mysql.ErrInvalidJSONPath])
// ErrInvalidJSONData means invalid JSON data.
ErrInvalidJSONData = terror.ClassJSON.New(mysql.ErrInvalidJSONData, mysql.MySQLErrName[mysql.ErrInvalidJSONData])
// ErrInvalidJSONPathWildcard means invalid JSON path that contain wildcard characters.
ErrInvalidJSONPathWildcard = terror.ClassJSON.New(mysql.ErrInvalidJSONPathWildcard, mysql.MySQLErrName[mysql.ErrInvalidJSONPathWildcard])
)

func init() {
terror.ErrClassToMySQLCodes[terror.ClassJSON] = map[terror.ErrCode]uint16{
mysql.ErrInvalidJSONText: mysql.ErrInvalidJSONText,
mysql.ErrInvalidJSONPath: mysql.ErrInvalidJSONPath,
mysql.ErrInvalidJSONData: mysql.ErrInvalidJSONData,
mysql.ErrInvalidJSONText: mysql.ErrInvalidJSONText,
mysql.ErrInvalidJSONPath: mysql.ErrInvalidJSONPath,
mysql.ErrInvalidJSONData: mysql.ErrInvalidJSONData,
mysql.ErrInvalidJSONPathWildcard: mysql.ErrInvalidJSONPathWildcard,
}
}
5 changes: 5 additions & 0 deletions types/json/path_expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ func (pe PathExpression) popOneLastLeg() (PathExpression, pathLeg) {
return PathExpression{legs: pe.legs[:lastLegIdx]}, lastLeg
}

// ContainsAnyAsterisk returns true if pe contains any asterisk.
func (pe PathExpression) ContainsAnyAsterisk() bool {
return pe.flags.containsAnyAsterisk()
}

// ParseJSONPathExpr parses a JSON path expression. Returns a PathExpression
// object which can be used in JSON_EXTRACT, JSON_SET and so on.
func ParseJSONPathExpr(pathExpr string) (pe PathExpression, err error) {
Expand Down

0 comments on commit 3f5bd3b

Please sign in to comment.