Repository: genjidb/genji
Pull request #291 (2020-10-25 10:42:20)

Add simple contributing file
Because Genji is starting to get more and more contributions, I think this is the right time to add a CONTRIBUTING.md file.
The purpose of this file is to establish some clear guidelines on how we want to work together and to make sure any important change is discussed before anyone starts spending energy on it.
As a maintainer, my role is to structure this project, and obviously not to find all solutions myself.
This PR is my attempt to make sure contributions are coherent, and make sure anyone (not only maintainers) can give their opinion.


Patch:
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 00000000..73fc7bd1
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,55 @@
+# Contributing
+
+You can participate in Genji in several ways, there is so much to do to make this project awesome!
+
+  - [Reporting a bug](#reporting-a-bug)
+  - [Proposing an SQL feature or a general design change](#proposing-an-sql-feature-or-a-general-design-change)
+  - [Other contributions](#other-contributions)
+
+The goal of this document is to provide guidelines to contributors to make working with Genji a smooth experience.
+Genji is a complex project that has many moving parts and we want to make sure that everyone's working towards the same goal.
+
+We want to avoid situations where you would put energy on a contribution that gets rejected because it doesn't match the direction
+this project is taking.
+
+## Reporting a bug
+
+We use Github issues to track bugs. Report a bug by [opening an issue](https://github.com/genjidb/genji/issues/new), it's that easy!
+
+Make sure you give enough context for us to reproduce the bug:
+
+- The version of Genji you are using
+- The observed behavior
+- The expected behavior
+- Ideally, some code like a main file or a Go [playground](https://play.golang.org/) link
+
+## Proposing an SQL feature or a general design change
+
+Genji is not a common database because it mixes documents with SQL. Some SQL features that make sense in mainstream relational databases may not be a good fit for this project and vice versa.
+Every feature can have a big impact on the project, including performance, ordering, encoding, etc.
+To propose a consistent solution to users, every SQL feature needs to be thoroughly designed and discussed before even starting implementing it.
+
+- Open an issue describing the SQL feature you want Genji to include
+- Discuss it with maintainers and contributors
+- If necessary, someone will be in charge of writing an RFC
+- Once the feature is accepted, someone will be in charge of creating a Pull Request
+
+Depending on the complexity and importance of the feature, the RFC and the PR may or may not be assigned to you. However, we will always do our best to let you contribute code if you are eager to do so and the feature doesn't require too much context.
+
+## Other contributions
+
+Anything that can help improve Genji is a welcome contribution. Here are a few examples of things that can be improved:
+
+- CI/Tooling
+- Go APIs
+- Documentation
+- Build targets support
+- Performance
+
+In most cases, contributing to Genji must follow these guidelines:
+
+- Open an issue
+- Discuss with maintainers and contributors
+- Open a PR to propose your solution to the problem
+
+If the change is really small, like correcting a typo or a very obvious bug fix, you may open a PR directly.
diff --git a/README.md b/README.md
index 4805bf96..bd997f0c 100644
--- a/README.md
+++ b/README.md
@@ -290,10 +290,8 @@ genji --badger pathToData
 
 Contributions are welcome!
 
-- Feedback
-- Feature ideas
-- Bug reports
-- Pull Requests
-- Anything that comes to mind to improve Genji!
+See [CONTRIBUTING.md](.github/CONTRIBUTING.md).
+
+Thank you, [contributors](https://github.com/genjidb/genji/graphs/contributors)!
 
 If you have any doubt, join the [Gophers Slack channel](https://gophers.slack.com/messages/CKPCYQFE0) or open an [issue](https://github.com/genjidb/genji/issues/new).
diff --git a/rfcs/001_context.md b/rfcs/001_context.md
new file mode 100644
index 00000000..2695ad91
--- /dev/null
+++ b/rfcs/001_context.md
@@ -0,0 +1,123 @@
+- Feature name: `Cancelable operations and use of Context`
+- Date: 2020-10-10
+- Author: Asdine El Hrychy
+- Related discussions: [Issue #224](https://github.com/genjidb/genji/issues/224), [PR #231](https://github.com/genjidb/genji/pull/231)
+
+## General approach
+
+I would like to avoid context pollution as much as possible, but without restricting user flexibility. It is important to be able to control and cancel operations but I also believe that there are times where we don't care about that.
+I want Genji to be simple to use and intuitive, and for that, I prefer to expand a bit more the surface API, rather than making the existing ones more complete and complex.
+The purpose of this RFC is not to describe in detail how each operation should be canceled but rather which APIs should expect a context parameter.
+
+## Genji package and database lifecycle
+
+### Open and New
+
+Opening a database relies on IO and should be cancelable, but after that operation is completed the database handle itself is long-lived and should not be cancelable, but closable with the `Close()` method.
+However, most of the time, we don't care about how long it takes to open a database.
+
+We currently have two ways of opening a database:
+
+- `genji.Open`
+- `genji.New`
+
+`Open` is just a specialized way of opening a database which calls `New`. It is by nature incomplete:
+
+- It only allows opening an in-memory database or a Bolt database
+- It doesn't take any option
+
+```go
+db, err := genji.Open("foo.db")
+if err != nil {
+    return err
+}
+defer db.Close()
+```
+
+It is very simple to write and to use, it must remain as-is.
+
+If users want more power, they must use the `genji.New` function.
+
+Opening a database can take time, even for Bolt or Badger, because accessing disks is unbounded (slow disks, NFS, etc.). It makes sense to have this a cancelable operation.
+
+```go
+ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
+...
+
+// ctx here is only used to control how long the engine creation can take
+// as engines might wait for IO
+ng := someengine.New(ctx, ...)
+// but not all of them
+ng := memoryengine.New()
+
+// at startup, Genji performs some operations on the engine, this should be cancelable
+db, err := genji.New(ctx, ng)
+```
+
+### genji.DB methods
+
+By default, database methods must not expect a context. This avoids context pollution when cancelation is not necessary.
+However, when it is necessary, a new handle can be created to use a specific context, using a new method named `WithContext`.
+
+```go
+// non-cancelable queries
+db.Exec("INSERT INTO foo(a) VALUES (1)")
+db.Query("SELECT 1")
+db.QueryDocument("SELECT 1")
+
+ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
+
+// WithContext returns a cancelable database handle
+db.WithContext(ctx).Exec("INSERT INTO foo (a) VALUES (1)")
+
+dbx := db.WithContext(ctx)
+
+// any called dbx method must return an error if the context is canceled.
+dbx.Query("SELECT 1")
+dbx.Begin(true)
+```
+
+## Engines
+
+Since almost every engine action is done from within a transaction, cancelation must be controlled by the context passed to the `Begin` method. Any action done on a canceled transaction must return an error.
+
+In addition, context cancellation or some unrecoverable I/O error may occur when iterating over store with `engine.Iterator`. In such case iterator is invalidated, `Valid()` method returns false and the new `Err()` will return non-nil error.
+
+```go
+ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
+
+// only the Begin method takes the context.
+tx, err := ng.Begin(ctx, true)
+
+// Transaction methods don't need to take a context.
+st, err := tx.GetStore([]byte("foo"))
+err = st.Put([]byte("a"), []byte("b"))
+
+it := st.NewIterator(engine.IteratorConfig{})
+defer it.Close()
+for it.Seek(nil); it.Valid(); it.Next() {
+	// …
+}
+if err := it.Err(); err != nil {
+	return err
+}
+```
+
+### Packages that use an engine
+
+Since the context is passed only to `engine.Engine#Begin`, only the functions that explicitly need to open a transaction must expect a context themselves.
+
+```go
+package database
+
+func (db *Database) Begin(ctx context.Context, writable bool) (*Transaction, error)
+func (db *Database) BeginTx(ctx context.Context, writable bool) (*Transaction, error)
+```
+
+Any function using the returned transaction must not expect a context in their signature.
+
+However, there might be some exceptions: If a function performs a long-running operation or relies on IO itself, it must expect a context.
+
+### Other packages
+
+If a function doesn't rely on IO, it must not expect a context.
diff --git a/sql/parser/expr.go b/sql/parser/expr.go
index d658185c..39cbad44 100644
--- a/sql/parser/expr.go
+++ b/sql/parser/expr.go
@@ -132,10 +132,17 @@ func (p *Parser) parseOperator() (func(lhs, rhs expr.Expr) expr.Expr, scanner.To
 		p.Unscan()
 		return expr.Is, op, nil
 	case scanner.NOT:
-		if tok, pos, lit := p.ScanIgnoreWhitespace(); tok != scanner.IN {
-			return nil, 0, newParseError(scanner.Tokstr(tok, lit), []string{"IN"}, pos)
+		tok, pos, lit := p.ScanIgnoreWhitespace()
+		switch tok {
+		case scanner.IN:
+			return expr.NotIn, op, nil
+		case scanner.LIKE:
+			return expr.NotLike, op, nil
 		}
-		return expr.NotIn, op, nil
+
+		return nil, 0, newParseError(scanner.Tokstr(tok, lit), []string{"IN, LIKE"}, pos)
+	case scanner.LIKE:
+		return expr.Like, op, nil
 	}
 
 	panic(fmt.Sprintf("unknown operator %q", op))
diff --git a/sql/query/expr/comparison.go b/sql/query/expr/comparison.go
index 5415d605..92423ac8 100644
--- a/sql/query/expr/comparison.go
+++ b/sql/query/expr/comparison.go
@@ -426,7 +426,7 @@ func (op cmpOp) compare(l, r document.Value) (bool, error) {
 func IsComparisonOperator(op Operator) bool {
 	switch op.(type) {
 	case eqOp, neqOp, gtOp, gteOp, ltOp, lteOp,
-		isOp, isNotOp, inOp, notInOp:
+		isOp, isNotOp, inOp, notInOp, likeOp, notLikeOp:
 		return true
 	}
 
@@ -540,17 +540,7 @@ func NotIn(a, b Expr) Expr {
 }
 
 func (op notInOp) Eval(ctx EvalStack) (document.Value, error) {
-	v, err := op.inOp.Eval(ctx)
-	if err != nil {
-		return v, err
-	}
-	if v == trueLitteral {
-		return falseLitteral, nil
-	}
-	if v == falseLitteral {
-		return trueLitteral, nil
-	}
-	return v, nil
+	return invertBoolResult(op.inOp.Eval)(ctx)
 }
 
 func (op notInOp) String() string {
diff --git a/sql/query/expr/expr.go b/sql/query/expr/expr.go
index d22de393..fd949199 100644
--- a/sql/query/expr/expr.go
+++ b/sql/query/expr/expr.go
@@ -132,3 +132,20 @@ type Parentheses struct {
 func (p Parentheses) Eval(es EvalStack) (document.Value, error) {
 	return p.E.Eval(es)
 }
+
+func invertBoolResult(f func(ctx EvalStack) (document.Value, error)) func(ctx EvalStack) (document.Value, error) {
+	return func(ctx EvalStack) (document.Value, error) {
+		v, err := f(ctx)
+
+		if err != nil {
+			return v, err
+		}
+		if v == trueLitteral {
+			return falseLitteral, nil
+		}
+		if v == falseLitteral {
+			return trueLitteral, nil
+		}
+		return v, nil
+	}
+}
diff --git a/sql/query/expr/like.go b/sql/query/expr/like.go
new file mode 100644
index 00000000..6bbc2c77
--- /dev/null
+++ b/sql/query/expr/like.go
@@ -0,0 +1,60 @@
+package expr
+
+import (
+	"errors"
+	"fmt"
+	"github.com/genjidb/genji/document"
+	"github.com/genjidb/genji/sql/query/glob"
+	"github.com/genjidb/genji/sql/scanner"
+)
+
+func like(pattern, text string) bool {
+	return glob.MatchLike(pattern, text)
+}
+
+type likeOp struct {
+	*simpleOperator
+}
+
+// Like creates an expression that evaluates to the result of a LIKE b.
+func Like(a, b Expr) Expr {
+	return &likeOp{&simpleOperator{a, b, scanner.LIKE}}
+}
+
+func (op likeOp) Eval(ctx EvalStack) (document.Value, error) {
+	a, b, err := op.simpleOperator.eval(ctx)
+	if err != nil {
+		return nullLitteral, err
+	}
+
+	if a.Type != document.TextValue || b.Type != document.TextValue {
+		return nullLitteral, errors.New("LIKE operator takes a text")
+	}
+
+	if like(b.V.(string), a.V.(string)) {
+		return trueLitteral, nil
+	}
+
+	return falseLitteral, nil
+}
+
+func (op likeOp) String() string {
+	return fmt.Sprintf("%v LIKE %v", op.a, op.b)
+}
+
+type notLikeOp struct {
+	likeOp
+}
+
+// NotLike creates an expression that evaluates to the result of a NOT LIKE b.
+func NotLike(a, b Expr) Expr {
+	return &notLikeOp{likeOp{&simpleOperator{a, b, scanner.LIKE}}}
+}
+
+func (op notLikeOp) Eval(ctx EvalStack) (document.Value, error) {
+	return invertBoolResult(op.likeOp.Eval)(ctx)
+}
+
+func (op notLikeOp) String() string {
+	return fmt.Sprintf("%v NOT LIKE %v", op.a, op.b)
+}
diff --git a/sql/query/glob/doc.go b/sql/query/glob/doc.go
new file mode 100644
index 00000000..12dfb74b
--- /dev/null
+++ b/sql/query/glob/doc.go
@@ -0,0 +1,3 @@
+// Package glob implements wildcard pattern matching algorithms for strings.
+//
+package glob
diff --git a/sql/query/glob/like.go b/sql/query/glob/like.go
new file mode 100644
index 00000000..9454f0c6
--- /dev/null
+++ b/sql/query/glob/like.go
@@ -0,0 +1,194 @@
+// The author disclaims copyright to this source code.  In place of
+// a legal notice, here is a blessing:
+//
+//    May you do good and not evil.
+//    May you find forgiveness for yourself and forgive others.
+//    May you share freely, never taking more than you give.
+//
+// This is an optimized Go port of the SQLite’s icuLikeCompare routine using backtracking.
+// See https://sqlite.org/src/file?name=ext%2Ficu%2Ficu.c&ln=117-195&ci=54b54f02c66c5aea
+
+package glob
+
+import (
+	"unicode"
+	"unicode/utf8"
+)
+
+const (
+	matchOne = '_'
+	matchAll = '%'
+	matchEsc = '\\'
+)
+
+// readRune is like skipRune, but also returns the removed Unicode code point.
+func readRune(s string) (rune, string) {
+	r, size := utf8.DecodeRuneInString(s)
+	if r == utf8.RuneError && size == 1 {
+		return rune(s[0]), s[1:]
+	}
+	return r, s[size:]
+}
+
+// skipRune returns a slice of the string s with the first Unicode code point removed.
+func skipRune(s string) string {
+	_, size := utf8.DecodeRuneInString(s)
+	return s[size:]
+}
+
+// equalFold is strings.EqualFold for individual runes.
+func equalFold(sr, tr rune) bool {
+	// Easy case.
+	if tr == sr {
+		return true
+	}
+
+	// Make sr < tr to simplify what follows.
+	if tr < sr {
+		tr, sr = sr, tr
+	}
+	// Fast check for ASCII.
+	if tr < utf8.RuneSelf {
+		// ASCII only, sr/tr must be upper/lower case
+		return 'A' <= sr && sr <= 'Z' && tr == sr+'a'-'A'
+	}
+
+	// General case. SimpleFold(x) returns the next equivalent rune > x
+	// or wraps around to smaller values.
+	r := unicode.SimpleFold(sr)
+	for r != sr && r < tr {
+		r = unicode.SimpleFold(r)
+	}
+	return r == tr
+}
+
+// MatchLike reports whether string s matches the SQL LIKE-style glob pattern.
+// Supported wildcards are '_' (match any one character) and '%' (match zero
+// or more characters). They can be escaped by '\' (escape character).
+//
+// MatchLike requires pattern to match whole string, not just a substring.
+func MatchLike(pattern, s string) bool {
+	var prevEscape bool
+
+	var w, t string // backtracking state
+
+	for len(s) != 0 {
+		// Read (and consume) the next character from the input pattern.
+		var p rune
+		if len(pattern) == 0 {
+			goto backtrack
+		}
+		p, pattern = readRune(pattern)
+
+	loop:
+		// There are now 4 possibilities:
+		//
+		// 1. p is an unescaped matchAll character “%”,
+		// 2. p is an unescaped matchOne character “_”,
+		// 3. p is an unescaped matchEsc character, or
+		// 4. p is to be handled as an ordinary character
+		//
+		if p == matchAll && !prevEscape {
+			// Case 1.
+			var c byte
+
+			// Skip any matchAll or matchOne characters that follow a
+			// matchAll. For each matchOne, skip one character in the
+			// test string.
+			//
+			for len(pattern) != 0 {
+				c = pattern[0]
+				if c != matchAll && c != matchOne {
+					break
+				}
+				pattern = pattern[1:]
+
+				if c != matchOne {
+					continue
+				}
+				if len(s) == 0 {
+					return false
+				}
+				s = skipRune(s)
+			}
+
+			if len(pattern) == 0 {
+				return true
+			}
+
+			// Save state and match next character.
+			//
+			// Since we save t = s and then continue to loop for len(s) ≠ 0,
+			// the condition len(t) ≠ 0 is always true when we need to backtrack.
+			//
+			w, t = pattern, s
+		} else if p == matchOne && !prevEscape {
+			// Case 2.
+			//
+			// We can either enter loop on normal iteration where len(s) ≠ 0,
+			// or from backtracking. But we consume all matchOne characters
+			// before saving backtracking state, so this case is reachable on
+			// normal iteration only.
+			//
+			// That is, we are guaranteed to have input at this point.
+			//
+			s = skipRune(s)
+		} else if p == matchEsc && !prevEscape {
+			// Case 3.
+			//
+			// We can’t reach this case from backtracking to matchAll.
+			// That implies len(s) ≠ 0 and normal iteration on continue.
+			// We would either have an escaped character in the pattern,
+			// or we’ve consumed whole pattern and attempt to backtrack.
+			// If we can’t backtrack then we are not at the end of input
+			// since len(s) ≠ 0, and false is returned. That said, it’s
+			// impossible to exit the loop with truthy prevEscape.
+			//
+			prevEscape = true
+		} else {
+			// Case 4.
+			prevEscape = false
+
+			var r rune
+			r, s = readRune(s)
+			if !equalFold(p, r) {
+				goto backtrack
+			}
+		}
+		continue
+
+	backtrack:
+		// If we can’t backtrack return prevEscape
+		// to allow escaping end of input.
+		//
+		if len(w) == 0 {
+			return prevEscape && len(s) == 0
+		}
+
+		// Keep the pattern and skip rune in input.
+		// Note that we only backtrack to matchAll.
+		//
+		p, pattern = matchAll, w
+		prevEscape = false
+		s = skipRune(t)
+
+		goto loop
+	}
+
+	// Check that the rest of the pattern is matchAll.
+	for i := 0; i < len(pattern); i++ {
+		if pattern[i] == matchAll {
+			continue
+		}
+
+		// Allow escaping end of string.
+		if i+1 == len(pattern) {
+			if pattern[i] == matchEsc {
+				return true
+			}
+		}
+
+		return false
+	}
+	return true
+}
diff --git a/sql/scanner/token.go b/sql/scanner/token.go
index c18d3114..695733d1 100644
--- a/sql/scanner/token.go
+++ b/sql/scanner/token.go
@@ -56,6 +56,7 @@ const (
 	GTE      // >=
 	IN       // IN
 	IS       // IS
+	LIKE     // LIKE
 	operatorEnd
 
 	LPAREN      // (
@@ -180,6 +181,7 @@ var tokens = [...]string{
 	GTE:      ">=",
 	IN:       "IN",
 	IS:       "IS",
+	LIKE:     "LIKE",
 
 	LPAREN:      "(",
 	RPAREN:      ")",
@@ -267,7 +269,7 @@ func initKeywords() {
 	for tok := keywordBeg + 1; tok < keywordEnd; tok++ {
 		keywords[strings.ToLower(tokens[tok])] = tok
 	}
-	for _, tok := range []Token{AND, OR, TRUE, FALSE, NULL, IN, IS} {
+	for _, tok := range []Token{AND, OR, TRUE, FALSE, NULL, IN, IS, LIKE} {
 		keywords[strings.ToLower(tokens[tok])] = tok
 	}
 }
@@ -289,7 +291,7 @@ func (tok Token) Precedence() int {
 		return 2
 	case IN:
 		return 3
-	case EQ, NEQ, EQREGEX, NEQREGEX, LT, LTE, GT, GTE, IS:
+	case EQ, NEQ, EQREGEX, NEQREGEX, LT, LTE, GT, GTE, IS, LIKE:
 		return 4
 	case ADD, SUB, BITWISEOR, BITWISEXOR:
 		return 5


Test patch:
diff --git a/sql/query/glob/like_test.go b/sql/query/glob/like_test.go
new file mode 100644
index 00000000..5c7cf4ae
--- /dev/null
+++ b/sql/query/glob/like_test.go
@@ -0,0 +1,202 @@
+package glob
+
+import (
+	"testing"
+)
+
+func TestMatchLike(t *testing.T) {
+	tests := []struct {
+		s, pattern string
+		want       bool
+	}{
+		// Empty
+		{"", "", true},
+		{"", "x", false},
+		{"x", "", false},
+
+		// One
+		{"", "_", false},
+		{"x", "_", true},
+		{"x", "__", false},
+		{"xx", "_", false},
+		{"bLah", "bL_h", true},
+		{"bLaaa", "bLa_", false},
+		{"bLah", "bLa_", true},
+		{"bLaH", "_Lah", true},
+		{"bLaH", "_LaH", true},
+
+		// All
+		{"", "%", true},
+		{"abc", "%", true},
+		{"", "%", true},
+		{"abc", "%%", true},
+
+		// Any and one
+		{"x", "%_", true},
+		{"", "%_", false},
+
+		// Escape
+		{"", "\\", true},
+		{"", "%\\", true},
+		{"", "\\%", false},
+		{"x", "%\\", true},
+		{"x", "\\%", false},
+		{"x", "_\\", true},
+		{"x", "_\\x", false},
+		{"%", "\\%", true},
+		{"_", "\\_", true},
+		{"x", "\\%", false},
+		{"x", "\\_", false},
+		{"x", "\\x", true},
+		{"ab", "a\\", false},
+		{"ab", "\\b", false},
+
+		// Escaping escape
+		{"", "\\\\", false},
+		{"x", "\\\\", false},
+		{"\\", "\\\\", true},
+		{"\\", "%\\\\", true},
+		{"\\", "\\\\%", true},
+		{"\\", "_\\\\", false},
+		{"\\", "\\\\_", false},
+		{"x\\", "\\x\\", false},
+		{"\\x", "\\\\x", true},
+
+		// Exact
+		{"abc", "abc", true},
+		{"aBc", "AbC", true},
+		{"abc", "def", false},
+
+		// Case folding
+		{"K", "\u212A", true}, // K → k → U+212A
+		{"\u212A", "k", true},
+
+		// Invalid UTF-8
+		{"\xFF", "\xFF", true},
+		{"\xFA", "\xFB", false},
+		{"\xFF", "_", true},
+		{"\xFF", "\xFF_", false},
+		{"\xFF", "%", true},
+		{"\xFF", "%\xFF%", true},
+		{"\xFF", "x", false},
+
+		// Prefix
+		{"abc", "abc%", true},
+		{"abcdef", "abc%", true},
+		{"abcdef", "def%", false},
+
+		// Suffix
+		{"abc", "%abc", true},
+		{"defabc", "%abc", true},
+		{"defabc", "%def", false},
+
+		// Contains
+		{"defabcdef", "%abc%", true},
+		{"abcd", "%def%", false},
+		{"abc", "b", false},
+
+		// Complex
+		{"abc", "ab%d", false},
+		{"ABCD", "%B%C%", true},
+		{"ABxCxxD", "a%b%c%d", true},
+		{"a", "__", false},
+		{"ab", "__", true},
+		{"abc", "___", true},
+		{"abcd", "____", true},
+		{"abc", "____", false},
+		{"abcd", "_b__", true},
+		{"abcd", "_a__", false},
+		{"abcd", "__c_", true},
+		{"abcd", "__d_", false},
+
+		// Mixed
+		{"", "%_", false},
+		{"", "_%", false},
+		{"a", "%_", true},
+		{"a", "%__", false},
+		{"ab", "%_", true},
+		{"abc", "%_", true},
+		{"ab", "_%_", true},
+		{"ab", "%_%_%", true},
+		{"aab", "%b_", false},
+		{"aaaa", "_aa%", true},
+		{"aaaa", "%aa_", true},
+		{"abc", "_%%_%_", true},
+		{"abc", "_%%_%&_", false},
+		{"abcd", "_b%__", true},
+		{"abcd", "_a%__", false},
+		{"abcd", "_%%_c_", true},
+		{"abcd", "_%%_d_", false},
+		{"abcde", "_b_d%_", true},
+		{"abcde", "_%b%_%d%_", true},
+		{"abcd", "_%b%c%_", true},
+		{"ABxCxxD", "%__B", false},
+		{"abBbc", "%b_c", true},
+
+		// Longer strings
+		{
+			"%abc%",
+			"%%\\%a%b%c\\%%%",
+			true,
+		},
+		{
+			"aaabbaabbaab",
+			"%aabbaa%a%",
+			true,
+		},
+		{
+			"abacaaadabacababacaaabadagabacaba",
+			"%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%",
+			true,
+		},
+		{
+			"aaaaaaaaaaaaaaaa",
+			"%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%",
+			false,
+		},
+		{
+			"%a%b%c%",
+			"%%%%%%%%a%%%%\\%%%%b%%%%\\%%%%c%%%%%%%%",
+			true,
+		},
+		{
+			"a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%a%",
+			"a%a\\%a%a\\%a%a\\%a%a\\%a%a\\%a%a\\%a%a\\%a%a\\%a%",
+			true,
+		},
+		{
+			"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab",
+			"a%a%a%a%a%a%aa%aaa%a%a%b",
+			true,
+		},
+		{
+			"abababababababababababababababababababaacacacacacacacadaeafagahaiajakalaaaaaaaaaaaaaaaaaffafagaagggagaaaaaaaab",
+			"%a%b%ba%ca%a%aa%aaa%fa%ga%b%",
+			true,
+		},
+		{
+			"abababababababababababababababababababaacacacacacacacadaeafagahaiajakalaaaaaaaaaaaaaaaaaffafagaagggagaaaaaaaab",
+			"%a%b%ba%ca%a%x%aaa%fa%ga%b%",
+			false,
+		},
+		{
+			"abababababababababababababababababababaacacacacacacacadaeafagahaiajakalaaaaaaaaaaaaaaaaaffafagaagggagaaaaaaaab",
+			"%a%b%ba%ca%aaaa%fa%ga%gggg%b%",
+			false,
+		},
+		{
+			"abababababababababababababababababababaacacacacacacacadaeafagahaiajakalaaaaaaaaaaaaaaaaaffafagaagggagaaaaaaaab",
+			"%a%b%ba%ca%aaaa%fa%ga%ggg%b%",
+			true,
+		},
+	}
+
+	for _, test := range tests {
+		if got := MatchLike(test.pattern, test.s); got != test.want {
+			t.Errorf(
+				"MatchLike(%#v, %#v): expected %#v, got %#v",
+				test.pattern, test.s, test.want, got,
+			)
+		}
+	}
+}
diff --git a/sql/scanner/scanner_test.go b/sql/scanner/scanner_test.go
index 0975d10e..5e661cfe 100644
--- a/sql/scanner/scanner_test.go
+++ b/sql/scanner/scanner_test.go
@@ -54,6 +54,7 @@ func TestScanner_Scan(t *testing.T) {
 		{s: `>=`, tok: scanner.GTE, raw: `>=`},
 		{s: `IN`, tok: scanner.IN, raw: `IN`},
 		{s: `IS`, tok: scanner.IS, raw: `IS`},
+		{s: `LIKE`, tok: scanner.LIKE, raw: `LIKE`},
 
 		// Misc tokens
 		{s: `(`, tok: scanner.LPAREN, raw: `(`},