Skip to content
This repository has been archived by the owner on Jan 28, 2021. It is now read-only.

Suggest similar table/column/indexes names on missing errors #685

Merged
merged 4 commits into from
Apr 23, 2019
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
7 changes: 6 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ There are two authentication methods:
- **None:** no authentication needed.
- **Native:** authentication performed with user and password. Read, write or all permissions can be specified for those users. It can also be configured using a JSON file.

## `internal/similartext`

juanjux marked this conversation as resolved.
Show resolved Hide resolved
Contains a function to `Find` the most similar name from an
array to a given one using the Levenshtein distance algorithm. Used for suggestions on errors.

## `internal/regex`

go-mysql-server has multiple regular expression engines, such as oniguruma and the standard Go regexp engine. In this package, a common interface for regular expression engines is defined.
Expand Down Expand Up @@ -134,4 +139,4 @@ After parsing, the obtained execution plan is analyzed using the analyzer define

If indexes can be used, the analyzer will transform the query so it uses indexes reading from the drivers in `sql/index` (in this case `sql/index/pilosa` because there is only one driver).

Once the plan is analyzed, it will be executed recursively from the top of the tree to the bottom to obtain the results and they will be sent back to the client using the MySQL wire protocol.
Once the plan is analyzed, it will be executed recursively from the top of the tree to the bottom to obtain the results and they will be sent back to the client using the MySQL wire protocol.
107 changes: 107 additions & 0 deletions internal/similartext/similartext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package similartext

import (
"fmt"
"reflect"
"strings"
)

func min(a, b int) int {
if a < b {
return a
}
return b
}

// DistanceForStrings returns the edit distance between source and target.
// It has a runtime proportional to len(source) * len(target) and memory use
// proportional to len(target).
// Taken (simplified, for strings and with default options) from:
// https://github.com/texttheater/golang-levenshtein
func distanceForStrings(source, target string) int {
height := len(source) + 1
width := len(target) + 1
matrix := make([][]int, 2)

for i := 0; i < 2; i++ {
matrix[i] = make([]int, width)
matrix[i][0] = i
}
for j := 1; j < width; j++ {
matrix[0][j] = j
}

for i := 1; i < height; i++ {
cur := matrix[i%2]
prev := matrix[(i-1)%2]
cur[0] = i
for j := 1; j < width; j++ {
delCost := prev[j] + 1
matchSubCost := prev[j-1]
if source[i-1] != target[j-1] {
matchSubCost += 2
}
insCost := cur[j-1] + 1
cur[j] = min(delCost, min(matchSubCost, insCost))
}
}
return matrix[(height-1)%2][width-1]
}

// MaxDistanceIgnored is the maximum Levenshtein distance from which
// we won't consider a string similar at all and thus will be ignored.
var DistanceSkipped = 3

// Find returns a string with suggestions for name(s) in `names`
// similar to the string `src` until a max distance of `DistanceSkipped`.
func Find(names []string, src string) string {
if len(src) == 0 {
return ""
}

minDistance := -1
matchMap := make(map[int][]string)

for _, name := range names {
dist := distanceForStrings(name, src)
if dist >= DistanceSkipped {
continue
}

if minDistance == -1 || dist < minDistance {
minDistance = dist
}

matchMap[dist] = append(matchMap[dist], name)
}

if len(matchMap) == 0 {
return ""
}

return fmt.Sprintf(", maybe you mean %s?",
strings.Join(matchMap[minDistance], " or "))
}

// FindFromMap does the same as Find but taking a map instead
// of a string array as first argument.
func FindFromMap(names interface{}, src string) string {
rnames := reflect.ValueOf(names)
if rnames.Kind() != reflect.Map {
panic("Implementation error: non map used as first argument " +
"to FindFromMap")
}

t := rnames.Type()
if t.Key().Kind() != reflect.String {
panic("Implementation error: non string key for map used as " +
"first argument to FindFromMap")
}

var namesList []string
for _, kv := range rnames.MapKeys() {
namesList = append(namesList, kv.String())
}

return Find(namesList, src)
}
52 changes: 52 additions & 0 deletions internal/similartext/similartext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package similartext

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestFind(t *testing.T) {
require := require.New(t)

var names []string
res := Find(names, "")
require.Empty(res)

names = []string{"foo", "bar", "aka", "ake"}
res = Find(names, "baz")
require.Equal(", maybe you mean bar?", res)

res = Find(names, "")
require.Empty(res)

res = Find(names, "foo")
require.Equal(", maybe you mean foo?", res)

res = Find(names, "willBeTooDifferent")
require.Empty(res)

res = Find(names, "aki")
require.Equal(", maybe you mean aka or ake?", res)
}

func TestFindFromMap(t *testing.T) {
require := require.New(t)

var names map[string]int
res := FindFromMap(names, "")
require.Empty(res)

names = map[string]int {
"foo": 1,
"bar": 2,
}
res = FindFromMap(names, "baz")
require.Equal(", maybe you mean bar?", res)

res = FindFromMap(names, "")
require.Empty(res)

res = FindFromMap(names, "foo")
require.Equal(", maybe you mean foo?", res)
}
17 changes: 14 additions & 3 deletions sql/analyzer/resolve_columns.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import (
"sort"
"strings"

errors "gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-mysql-server.v0/sql"
"gopkg.in/src-d/go-mysql-server.v0/sql/expression"
"gopkg.in/src-d/go-mysql-server.v0/sql/plan"
"gopkg.in/src-d/go-vitess.v1/vt/sqlparser"
"gopkg.in/src-d/go-mysql-server.v0/internal/similartext"
)

func checkAliases(ctx *sql.Context, a *Analyzer, n sql.Node) (sql.Node, error) {
Expand Down Expand Up @@ -202,7 +203,12 @@ func qualifyColumns(ctx *sql.Context, a *Analyzer, n sql.Node) (sql.Node, error)
}

if _, ok := tables[col.Table()]; !ok {
return nil, sql.ErrTableNotFound.New(col.Table())
if len(tables) == 0 {
return nil, sql.ErrTableNotFound.New(col.Table())
}

similar := similartext.FindFromMap(tables, col.Table())
return nil, sql.ErrTableNotFound.New(col.Table() + similar)
}
}

Expand Down Expand Up @@ -406,11 +412,16 @@ func resolveColumns(ctx *sql.Context, a *Analyzer, n sql.Node) (sql.Node, error)
return &deferredColumn{uc}, nil

default:
if len(colMap) == 0 {
return nil, ErrColumnNotFound.New(uc.Name())
}

if table != "" {
return nil, ErrColumnTableNotFound.New(uc.Table(), uc.Name())
}

return nil, ErrColumnNotFound.New(uc.Name())
similar := similartext.FindFromMap(colMap, uc.Name())
return nil, ErrColumnNotFound.New(uc.Name() + similar)
}
}

Expand Down
20 changes: 17 additions & 3 deletions sql/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"
"sync"

"gopkg.in/src-d/go-mysql-server.v0/internal/similartext"

"gopkg.in/src-d/go-errors.v1"
)

Expand Down Expand Up @@ -93,14 +95,21 @@ type Databases []Database

// Database returns the Database with the given name if it exists.
func (d Databases) Database(name string) (Database, error) {

if len(d) == 0 {
return nil, ErrDatabaseNotFound.New(name)
}

name = strings.ToLower(name)
var dbNames []string
for _, db := range d {
if strings.ToLower(db.Name()) == name {
return db, nil
}
dbNames = append(dbNames, db.Name())
}

return nil, ErrDatabaseNotFound.New(name)
similar := similartext.Find(dbNames, name)
return nil, ErrDatabaseNotFound.New(name + similar)
}

// Add adds a new database.
Expand All @@ -118,6 +127,10 @@ func (d Databases) Table(dbName string, tableName string) (Table, error) {
tableName = strings.ToLower(tableName)

tables := db.Tables()
if len(tables) == 0 {
return nil, ErrTableNotFound.New(tableName)
}

// Try to get the table by key, but if the name is not the same,
// then use the slow path and iterate over all tables comparing
// the name.
Expand All @@ -129,7 +142,8 @@ func (d Databases) Table(dbName string, tableName string) (Table, error) {
}
}

return nil, ErrTableNotFound.New(tableName)
similar := similartext.FindFromMap(tables, tableName)
return nil, ErrTableNotFound.New(tableName + similar)
}

return table, nil
Expand Down
8 changes: 8 additions & 0 deletions sql/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func TestCatalogDatabase(t *testing.T) {
mydb := mem.NewDatabase("foo")
c.AddDatabase(mydb)

db, err = c.Database("flo")
require.EqualError(err, "database not found: flo, maybe you mean foo?")
require.Nil(db)

db, err = c.Database("foo")
require.NoError(err)
require.Equal(mydb, db)
Expand All @@ -73,6 +77,10 @@ func TestCatalogTable(t *testing.T) {
mytable := mem.NewTable("bar", nil)
db.AddTable("bar", mytable)

table, err = c.Table("foo", "baz")
require.EqualError(err, "table not found: baz, maybe you mean bar?")
require.Nil(table)

table, err = c.Table("foo", "bar")
require.NoError(err)
require.Equal(mytable, table)
Expand Down
9 changes: 7 additions & 2 deletions sql/functionregistry.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sql

import (
"gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-mysql-server.v0/internal/similartext"
)

// ErrFunctionAlreadyRegistered is thrown when a function is already registered
Expand Down Expand Up @@ -203,9 +204,13 @@ func (r FunctionRegistry) MustRegister(fn ...Function) {

// Function returns a function with the given name.
func (r FunctionRegistry) Function(name string) (Function, error) {
if len(r) == 0 {
return nil, ErrFunctionNotFound.New(name)
}

if fn, ok := r[name]; ok {
return fn, nil
}

return nil, ErrFunctionNotFound.New(name)
similar := similartext.FindFromMap(r, name)
return nil, ErrFunctionNotFound.New(name + similar)
}
12 changes: 11 additions & 1 deletion sql/index.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sql

import (
"gopkg.in/src-d/go-mysql-server.v0/internal/similartext"
"io"
"strings"
"sync"
Expand Down Expand Up @@ -548,6 +549,13 @@ func (r *IndexRegistry) AddIndex(
func (r *IndexRegistry) DeleteIndex(db, id string, force bool) (<-chan struct{}, error) {
r.mut.RLock()
var key indexKey

if len(r.indexes) == 0 {
return nil, ErrIndexNotFound.New(id)
}

var indexNames []string

for k, idx := range r.indexes {
if strings.ToLower(id) == idx.ID() {
if !force && !r.CanRemoveIndex(idx) {
Expand All @@ -558,11 +566,13 @@ func (r *IndexRegistry) DeleteIndex(db, id string, force bool) (<-chan struct{},
key = k
break
}
indexNames = append(indexNames, idx.ID())
}
r.mut.RUnlock()

if key.id == "" {
return nil, ErrIndexNotFound.New(id)
similar := similartext.Find(indexNames, id)
return nil, ErrIndexNotFound.New(id + similar)
}

var done = make(chan struct{}, 1)
Expand Down
13 changes: 10 additions & 3 deletions sql/plan/drop_index.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package plan

import (
errors "gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-mysql-server.v0/internal/similartext"
"gopkg.in/src-d/go-mysql-server.v0/sql"
)

Expand Down Expand Up @@ -51,9 +52,15 @@ func (d *DropIndex) RowIter(ctx *sql.Context) (sql.RowIter, error) {
return nil, ErrTableNotNameable.New()
}

table, ok := db.Tables()[n.Name()]
tables := db.Tables()
table, ok := tables[n.Name()]
if !ok {
return nil, sql.ErrTableNotFound.New(n.Name())
if len(tables) == 0 {
return nil, sql.ErrTableNotFound.New(n.Name())
}

similar := similartext.FindFromMap(tables, n.Name())
return nil, sql.ErrTableNotFound.New(n.Name() + similar)
}

index := d.Catalog.Index(db.Name(), d.Name)
Expand Down
Loading