Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions enginetest/queries/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -5554,6 +5554,23 @@ Select * from (
Query: `SELECT JSON_UNQUOTE(JSON_EXTRACT('{"xid":"hello"}', '$.xid')) = "hello"`,
Expected: []sql.Row{{true}},
},

{
Query: `SELECT JSON_QUOTE('"foo"')`,
Expected: []sql.Row{{`"\"foo\""`}},
},
{
Query: `SELECT JSON_QUOTE('[1, 2, 3]')`,
Expected: []sql.Row{{`"[1, 2, 3]"`}},
},
{
Query: `SELECT JSON_QUOTE('"\t\u0032"')`,
Expected: []sql.Row{{`"\"\tu0032\""`}},
},
{
Query: `SELECT JSON_QUOTE('"\t\\u0032"')`,
Expected: []sql.Row{{`"\"\t\\u0032\""`}},
},
{
Query: `SELECT JSON_EXTRACT('{"xid":"hello"}', '$.xid') = "hello"`,
Expected: []sql.Row{{true}},
Expand Down
58 changes: 57 additions & 1 deletion internal/strings/unquote.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"unicode/utf8"
)

// Unquote returns a json unquoted string.
// The implementation is taken from TiDB
// https://github.com/pingcap/tidb/blob/a594287e9f402037b06930026906547000006bb6/types/json/binary_functions.go#L89
func Unquote(s string) (string, error) {
Expand All @@ -16,7 +17,8 @@ func Unquote(s string) (string, error) {
if s[i] == '\\' {
i++
if i == len(s) {
return "", fmt.Errorf("Missing a closing quotation mark in string")
ret.WriteByte('\\')
break
}
switch s[i] {
case '"':
Expand Down Expand Up @@ -84,3 +86,57 @@ func decodeEscapedUnicode(s []byte) (char [4]byte, size int, err error) {
utf8.EncodeRune(char[0:size], rune(unicode))
return
}

// Quote returns a json quoted string with escape characters.
// The implementation is taken from TiDB:
// https://github.com/pingcap/tidb/blob/a594287e9f402037b06930026906547000006bb6/types/json/binary_functions.go#L155
func Quote(s string) string {
var escapeByteMap = map[byte]string{
'\\': "\\\\",
'"': "\\\"",
'\b': "\\b",
'\f': "\\f",
'\n': "\\n",
'\r': "\\r",
'\t': "\\t",
}

ret := new(bytes.Buffer)
ret.WriteByte('"')

start := 0
for i := 0; i < len(s); {
if b := s[i]; b < utf8.RuneSelf {
escaped, ok := escapeByteMap[b]
if ok {
if start < i {
ret.WriteString(s[start:i])
}
ret.WriteString(escaped)
i++
start = i
} else {
i++
}
} else {
c, size := utf8.DecodeRune([]byte(s[i:]))
if c == utf8.RuneError && size == 1 { // refer to codes of `binary.marshalStringTo`
if start < i {
ret.WriteString(s[start:i])
}
ret.WriteString(`\ufffd`)
i += size
start = i
continue
}
i += size
}
}

if start < len(s) {
ret.WriteString(s[start:])
}

ret.WriteByte('"')
return ret.String()
}
107 changes: 107 additions & 0 deletions sql/expression/function/json/json_quote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2024 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package json

import (
"fmt"
"reflect"

"github.com/dolthub/go-mysql-server/internal/strings"
"github.com/dolthub/go-mysql-server/sql"
"github.com/dolthub/go-mysql-server/sql/expression"
"github.com/dolthub/go-mysql-server/sql/types"
)

// JSONQuote (string)
//
// JSONQuote Quotes a string as a JSON value by wrapping it with double quote characters and escaping interior quote and
// other characters, then returning the result as a utf8mb4 string. Returns NULL if the argument is NULL. This function
// is typically used to produce a valid JSON string literal for inclusion within a JSON document. Certain special
// characters are escaped with backslashes per the escape sequences shown in Table 12.23, “JSON_UNQUOTE() Special
// Character Escape Sequences”:
// https://dev.mysql.com/doc/refman/8.0/en/json-modification-functions.html#json-unquote-character-escape-sequences
//
// https://dev.mysql.com/doc/refman/8.0/en/json-creation-functions.html#function_json-quote
type JSONQuote struct {
expression.UnaryExpression
}

var _ sql.FunctionExpression = (*JSONQuote)(nil)
var _ sql.CollationCoercible = (*JSONQuote)(nil)

// NewJSONQuote creates a new JSONQuote UDF.
func NewJSONQuote(json sql.Expression) sql.Expression {
return &JSONQuote{expression.UnaryExpression{Child: json}}
}

// FunctionName implements sql.FunctionExpression
func (js *JSONQuote) FunctionName() string {
return "json_quote"
}

// Description implements sql.FunctionExpression
func (js *JSONQuote) Description() string {
return "quotes a string as a JSON value and returns the result as a utf8mb4 string."
}

// String implements the fmt.Stringer interface.
func (js *JSONQuote) String() string {
return fmt.Sprintf("%s(%s)", js.FunctionName(), js.Child)
}

// Type implements the Expression interface.
func (*JSONQuote) Type() sql.Type {
return types.LongText
}

// CollationCoercibility implements the interface sql.CollationCoercible.
func (*JSONQuote) CollationCoercibility(ctx *sql.Context) (collation sql.CollationID, coercibility byte) {
return ctx.GetCharacterSet().BinaryCollation(), 4
}

// WithChildren implements the Expression interface.
func (js *JSONQuote) WithChildren(children ...sql.Expression) (sql.Expression, error) {
if len(children) != 1 {
return nil, sql.ErrInvalidChildrenNumber.New(js, len(children), 1)
}
return NewJSONQuote(children[0]), nil
}

// Eval implements the Expression interface.
func (js *JSONQuote) Eval(ctx *sql.Context, row sql.Row) (interface{}, error) {
typ := js.Child.Type()
if typ != types.Null && !types.IsText(typ) {
return nil, sql.ErrInvalidType.New(typ)
}

val, err := js.Child.Eval(ctx, row)
if val == nil || err != nil {
return val, err
}

ex, _, err := types.LongText.Convert(val)
if err != nil {
return nil, err
}
if ex == nil {
return nil, nil
}
str, ok := ex.(string)
if !ok {
return nil, sql.ErrInvalidType.New(reflect.TypeOf(ex).String())
}

return strings.Quote(str), nil
}
89 changes: 89 additions & 0 deletions sql/expression/function/json/json_quote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2024 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package json

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"

"github.com/dolthub/go-mysql-server/sql"
"github.com/dolthub/go-mysql-server/sql/expression"
"github.com/dolthub/go-mysql-server/sql/types"
)

func TestJSONQuote(t *testing.T) {
testCases := []struct {
arg sql.Expression
exp interface{}
err bool
}{
{
arg: expression.NewLiteral(true, types.Boolean),
err: true,
},
{
arg: expression.NewLiteral(123, types.Int64),
err: true,
},
{
arg: expression.NewLiteral(123.0, types.Float64),
err: true,
},
{
arg: expression.NewLiteral(types.MustJSON(`{"a": 1}`), types.JSON),
err: true,
},
{
arg: expression.NewLiteral(nil, types.Null),
exp: nil,
},
{
arg: expression.NewLiteral("abc", types.Text),
exp: `"abc"`,
},
{
arg: expression.NewLiteral(`[1, 2, 3]`, types.Text),
exp: `"[1, 2, 3]"`,
},
{
arg: expression.NewLiteral(`"\t\u0032"`, types.Text),
exp: `"\"\\t\\u0032\""`,
Comment on lines +63 to +64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be escaping the backslashes in the control characters? It seems like MySQL doesn't return the same result. I'm not sure I totally understand what MySQL is doing here though... so I'm probably missing something. I don't get why it drops the \ in \u0032.

mysql> SELECT JSON_QUOTE('"\t\u0032"');
+--------------------------+
| JSON_QUOTE('"\t\u0032"') |
+--------------------------+
| "\"\tu0032\""            |
+--------------------------+

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this has something to do with the shell vs string literals?
When compiling and running dolt sql, I get the same output:

tmp/main>  SELECT JSON_QUOTE('"\t\u0032"');
+--------------------------+
| JSON_QUOTE('"\t\u0032"') |
+--------------------------+
| "\"\tu0032\""            |
+--------------------------+
1 row in set (0.00 sec)

},
{
arg: expression.NewLiteral(`\`, types.Text),
exp: `"\\"`,
Comment on lines +67 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an error, same as the test case for json_unquote?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why json_unquote throws an error in the first place; I'm having trouble replicating in MySQL...

},
{
arg: expression.NewLiteral(`\b\f\n\r\t\"`, types.Text),
exp: `"\\b\\f\\n\\r\\t\\\""`,
Comment on lines +71 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this one, with MySQL, I'm getting:

mysql> select JSON_QUOTE('\b\f\n\r\t\"');
+----------------------------+
| JSON_QUOTE('\b\f\n\r\t\"') |
+----------------------------+
| "\bf\n\r\t\""              |
+----------------------------+

The MySQL docs call out that \f is a supported escape sequence, but it also says for non-supported escape sequences it ignores the \, which seems to be what it's doing here with \f.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

tmp/main> select json_quote("\b\f\n\r\t\"");
+----------------------------+
| json_quote("\b\f\n\r\t\"") |
+----------------------------+
| "\bf\n\r\t\""              |
+----------------------------+
1 row in set (0.00 sec)

},
}

for _, tt := range testCases {
t.Run(fmt.Sprintf("%v", tt.arg), func(t *testing.T) {
require := require.New(t)
js := NewJSONQuote(tt.arg)
res, err := js.Eval(sql.NewEmptyContext(), nil)
if tt.err {
require.Error(err)
return
}
require.NoError(err)
require.Equal(tt.exp, res)
})
}
}
5 changes: 5 additions & 0 deletions sql/expression/function/json/json_unquote.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ func (js *JSONUnquote) WithChildren(children ...sql.Expression) (sql.Expression,

// Eval implements the Expression interface.
func (js *JSONUnquote) Eval(ctx *sql.Context, row sql.Row) (interface{}, error) {
typ := js.Child.Type()
if typ != types.Null && !types.IsText(typ) && !types.IsJSON(typ) {
return nil, sql.ErrInvalidType.New(typ)
}

json, err := js.Child.Eval(ctx, row)
if json == nil || err != nil {
return json, err
Expand Down
Loading