Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for backtick and single-quote string delimiters #25

Merged
merged 4 commits into from
Sep 18, 2023
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
2 changes: 1 addition & 1 deletion GRAMMAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ A `condition` is any expression that evaluates to a result of type boolean.

A string delimited by quotes. You can escape quotes with a backslash `\"` and
you can escape a backslash with a second backslash `\\` . Supported delimiters:
double-quotes.
double-quotes, single-quotes, backtick.

* \<quote> \<string> \<quote>

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ parameterized SQL where clause.
Fields in your model can be compared with the following operators: `=`, `!=`,
`>=`, `<=`, `<`, `>`, `%` .

Double quotes `"` must be used to quote strings.
Strings must be quoted. Double quotes `"`, single quotes `'` or backticks ``
` `` can be used as delimiters. Users can choose whichever supported delimiter
makes it easier to quote their string.

Comparison operators can have optional leading/trailing whitespace.

Expand Down
33 changes: 21 additions & 12 deletions coverage/coverage.html
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,15 @@
"unicode"
)

// Delimiter used to quote strings
type Delimiter rune

const (
doubleQuote = '"'
backslash = '\\'
DoubleQuote Delimiter = '"'
SingleQuote Delimiter = '\''
Backtick Delimiter = '`'

backslash = '\\'
)

type lexStateFunc func(*lexer) (lexStateFunc, error)
Expand Down Expand Up @@ -391,7 +397,7 @@
case unicode.IsDigit(r) || r == '.':<span class="cov8" title="1">
l.unread()
return lexNumberState, nil</span>
case r == doubleQuote:<span class="cov8" title="1">
case isDelimiter(r):<span class="cov8" title="1">
l.unread()
return lexStringState, nil</span>
default:<span class="cov8" title="1">
Expand All @@ -413,11 +419,9 @@
// before we start looping, let's found out if we're scanning a quoted string
r := l.read()
delimiter := r
switch delimiter </span>{
case doubleQuote:<span class="cov8" title="1"></span>
default:<span class="cov8" title="1">
return nil, fmt.Errorf("%s: %w %q", op, ErrInvalidDelimiter, delimiter)</span>
}
if !isDelimiter(delimiter) </span><span class="cov8" title="1">{
return nil, fmt.Errorf("%s: %w %q", op, ErrInvalidDelimiter, delimiter)
}</span>
<span class="cov8" title="1">finalDelimiter := false

WriteToBuf:
Expand Down Expand Up @@ -479,10 +483,6 @@
}
}

// before emitting a token, do we have a special string? But, first let's
// check if we're dealing with a quoted string, since we want to support
// emitting string tokens for "and", "or" so those tokens can be used in
// comparison expr. Example: name % "Johnson and"
<span class="cov8" title="1">switch strings.ToLower(runesToString(l.current)) </span>{
case "and":<span class="cov8" title="1">
l.emit(andToken, "and")
Expand Down Expand Up @@ -674,6 +674,15 @@
_ = l.source.UnreadRune() // error ignore which only occurs when nothing has been previously read
_, _ = l.current.pop()
}</span>

func isDelimiter(r rune) bool <span class="cov8" title="1">{
switch Delimiter(r) </span>{
case DoubleQuote, SingleQuote, Backtick:<span class="cov8" title="1">
return true</span>
default:<span class="cov8" title="1">
return false</span>
}
}
</pre>

<pre class="file" id="file3" style="display: none">// Copyright (c) HashiCorp, Inc.
Expand Down
1 change: 1 addition & 0 deletions coverage/coverage.log
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
1692791808,98.2
1694895660,98.3
1695066606,98.4
2 changes: 1 addition & 1 deletion coverage/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 19 additions & 6 deletions lex.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ import (
"unicode"
)

// Delimiter used to quote strings
type Delimiter rune

const (
doubleQuote = '"'
backslash = '\\'
DoubleQuote Delimiter = '"'
SingleQuote Delimiter = '\''
Backtick Delimiter = '`'

backslash = '\\'
)

type lexStateFunc func(*lexer) (lexStateFunc, error)
Expand Down Expand Up @@ -86,7 +92,7 @@ func lexStartState(l *lexer) (lexStateFunc, error) {
case unicode.IsDigit(r) || r == '.':
l.unread()
return lexNumberState, nil
case r == doubleQuote:
case isDelimiter(r):
l.unread()
return lexStringState, nil
default:
Expand All @@ -108,9 +114,7 @@ func lexStringState(l *lexer) (lexStateFunc, error) {
// before we start looping, let's found out if we're scanning a quoted string
r := l.read()
delimiter := r
switch delimiter {
case doubleQuote:
default:
if !isDelimiter(delimiter) {
return nil, fmt.Errorf("%s: %w %q", op, ErrInvalidDelimiter, delimiter)
}
finalDelimiter := false
Expand Down Expand Up @@ -365,3 +369,12 @@ func (l *lexer) unread() {
_ = l.source.UnreadRune() // error ignore which only occurs when nothing has been previously read
_, _ = l.current.pop()
}

func isDelimiter(r rune) bool {
switch Delimiter(r) {
case DoubleQuote, SingleQuote, Backtick:
return true
default:
return false
}
}
44 changes: 42 additions & 2 deletions lex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,36 @@ func Test_lexKeywordState(t *testing.T) {
},
},
{
name: "quoted-value",
name: "quoted-value-double-quotes",
raw: `"value"`,
want: []token{
{Type: stringToken, Value: `value`},
{Type: eofToken, Value: ""},
},
},
{
name: "quoted-value-single-quote",
raw: `'value'`,
want: []token{
{Type: stringToken, Value: `value`},
{Type: eofToken, Value: ""},
},
},
{
name: "quoted-value-backtick",
raw: "`value`",
want: []token{
{Type: stringToken, Value: `value`},
{Type: eofToken, Value: ""},
},
},
{
name: "missing-delimiter",
raw: `"value`,
wantErrContains: `missing end of stringToken delimiter for "value`,
},
{
name: "quoted-value-with-escaped-quote",
name: "quoted-value-with-escaped-double-quote",
raw: `alice="val\"ue"`,
want: []token{
{Type: symbolToken, Value: "alice"},
Expand All @@ -93,6 +109,26 @@ func Test_lexKeywordState(t *testing.T) {
{Type: eofToken, Value: ""},
},
},
{
name: "quoted-value-with-escaped-single-quote",
raw: `alice='val\'ue'`,
want: []token{
{Type: symbolToken, Value: "alice"},
{Type: equalToken, Value: "="},
{Type: stringToken, Value: `val'ue`},
{Type: eofToken, Value: ""},
},
},
{
name: "quoted-value-with-escaped-backtick",
raw: "alice=`val\\`ue`",
want: []token{
{Type: symbolToken, Value: "alice"},
{Type: equalToken, Value: "="},
{Type: stringToken, Value: "val`ue"},
{Type: eofToken, Value: ""},
},
},
{
name: "trailing-backslash",
raw: `alice="value\\"`,
Expand Down Expand Up @@ -365,6 +401,10 @@ func Fuzz_lexerNextToken(f *testing.F) {
`"alice"`,
`"alice\\eve"`,
`"alice \"owns\" this restaurant`,
`'alice'`,
"'alice\\'s'",
"`alice`",
"`alice\\`s",
}
for _, tc := range tc {
f.Add(tc)
Expand Down
20 changes: 20 additions & 0 deletions mql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ func TestParse(t *testing.T) {
Args: []any{"alice", "[email protected]", "1", 21, 1.5},
},
},
{
name: "success-single-quote-delimiters",
query: "(name='alice' and email='[email protected]' and member_number = 1) or (age > 21 or length < 1.5)",
model: &testModel{},
want: &mql.WhereClause{
Condition: "(((name=? and email=?) and member_number=?) or (age>? or length<?))",
Args: []any{"alice", "[email protected]", "1", 21, 1.5},
},
},
{
name: "success-backtick-delimiters",
query: "(name=`alice` and email=`[email protected]` and member_number = 1) or (age > 21 or length < 1.5)",
model: &testModel{},
want: &mql.WhereClause{
Condition: "(((name=? and email=?) and member_number=?) or (age>? or length<?))",
Args: []any{"alice", "[email protected]", "1", 21, 1.5},
},
},
{
name: "null-string",
query: "name=\"null\"",
Expand Down Expand Up @@ -234,6 +252,8 @@ func Fuzz_mqlParse(f *testing.F) {
"(Name=\"Alice Eve\")",
`name="alice"`,
`name="alice\\eve"`,
`name='alice'`,
"name=`alice's`",
}
for _, tc := range tc {
f.Add(tc)
Expand Down
Loading