diff --git a/docs/generated/sql/bnf/stmt_block.bnf b/docs/generated/sql/bnf/stmt_block.bnf index 0e2cc6146e9d..f85b80269e5b 100644 --- a/docs/generated/sql/bnf/stmt_block.bnf +++ b/docs/generated/sql/bnf/stmt_block.bnf @@ -868,6 +868,7 @@ unreserved_keyword ::= | 'COMMITTED' | 'COMPACT' | 'COMPLETE' + | 'COMPLETIONS' | 'CONFLICT' | 'CONFIGURATION' | 'CONFIGURATIONS' diff --git a/pkg/sql/delegate/delegate.go b/pkg/sql/delegate/delegate.go index c5ca17d2b13a..dcfe7d1c136d 100644 --- a/pkg/sql/delegate/delegate.go +++ b/pkg/sql/delegate/delegate.go @@ -139,6 +139,9 @@ func TryDelegate( case *tree.ShowSchedules: return d.delegateShowSchedules(t) + case *tree.ShowCompletions: + return d.delegateShowCompletions(t) + case *tree.ControlJobsForSchedules: return d.delegateJobControl(ControlJobsDelegate{ Schedules: t.Schedules, diff --git a/pkg/sql/delegate/show_completions.go b/pkg/sql/delegate/show_completions.go new file mode 100644 index 000000000000..58c2ba54a683 --- /dev/null +++ b/pkg/sql/delegate/show_completions.go @@ -0,0 +1,122 @@ +package delegate + +import ( + "bytes" + "fmt" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/cockroachdb/cockroach/pkg/sql/lexbase" + "github.com/cockroachdb/cockroach/pkg/sql/parser" + "github.com/cockroachdb/cockroach/pkg/sql/sem/tree" + "github.com/cockroachdb/errors" +) + +func (d *delegator) delegateShowCompletions(n *tree.ShowCompletions) (tree.Statement, error) { + offsetVal, ok := n.Offset.AsConstantInt() + if !ok { + return nil, errors.Newf("invalid offset %v", n.Offset) + } + offset, err := strconv.Atoi(offsetVal.String()) + if err != nil { + return nil, err + } + + completions, err := runShowCompletions(n.Statement, offset) + if err != nil { + return nil, err + } + + if len(completions) == 0 { + return parse(`SELECT '' as completions`) + } + + var query bytes.Buffer + fmt.Fprint(&query, "SELECT @1 AS completions FROM (VALUES ") + + comma := "" + for _, completion := range completions { + fmt.Fprintf(&query, "%s(", comma) + lexbase.EncodeSQLString(&query, completion) + query.WriteByte(')') + comma = ", " + } + + fmt.Fprintf(&query, ")") + + return parse(query.String()) +} + +func runShowCompletions(stmt string, offset int) ([]string, error) { + if offset <= 0 || offset > len(stmt) { + return nil, nil + } + + // For simplicity, if we're on a whitespace, return no completions. + // Currently, parser. + // parser.TokensIgnoreErrors does not consider whitespaces + // after the last token. + // Ie "SELECT ", will only return one token being "SELECT". + // If we're at the whitespace, we do not want to return completion + // recommendations for "SELECT". + if unicode.IsSpace([]rune(stmt)[offset-1]) { + return nil, nil + } + + sqlTokens := parser.TokensIgnoreErrors(string([]rune(stmt)[:offset])) + if len(sqlTokens) == 0 { + return nil, nil + } + + sqlTokenStrings := make([]string, len(sqlTokens)) + for i, sqlToken := range sqlTokens { + sqlTokenStrings[i] = sqlToken.Str + } + + lastWordTruncated := sqlTokenStrings[len(sqlTokenStrings)-1] + + // If the offset is in the middle of a word, we return the full word. + // For example if the stmt is SELECT with offset 2, even though SEARCH would + // come first for "SE", we want to return "SELECT". + // Similarly, if we have SEL with offset 2, we want to return "SEL". + allSqlTokens := parser.TokensIgnoreErrors(stmt) + lastWordFull := allSqlTokens[len(sqlTokenStrings)-1] + if lastWordFull.Str != lastWordTruncated { + return []string{strings.ToUpper(lastWordFull.Str)}, nil + } + + return getCompletionsForWord(lastWordTruncated, lexbase.KeywordNames), nil +} + +// Binary search for range with matching prefixes +// Return the range of matching prefixes for w. +func binarySearch(w string, words []string) (int, int) { + // First binary search for the first string in the sorted lexbase.KeywordNames list + // that matches the prefix w. + left := sort.Search(len(words), func(i int) bool { return words[i] >= w }) + + // Binary search for the last string in the sorted lexbase.KeywordNames list that matches + // the prefix w. + right := sort.Search(len(words), func(i int) bool { return words[i][:min(len(words[i]), len(w))] > w }) + + return left, right +} + +func min(a int, b int) int { + if a < b { + return a + } + return b +} + +func getCompletionsForWord(w string, words []string) []string { + left, right := binarySearch(strings.ToLower(w), words) + completions := make([]string, right-left) + + for i, word := range words[left:right] { + completions[i] = strings.ToUpper(word) + } + return completions +} diff --git a/pkg/sql/delegate/show_completions_test.go b/pkg/sql/delegate/show_completions_test.go new file mode 100644 index 000000000000..28a2313b1214 --- /dev/null +++ b/pkg/sql/delegate/show_completions_test.go @@ -0,0 +1,95 @@ +package delegate + +import ( + "reflect" + "testing" +) + +func TestCompletions(t *testing.T) { + tests := []struct { + stmt string + offset int + expectedCompletions []string + }{ + { + stmt: "creat", + expectedCompletions: []string{"CREATE", "CREATEDB", "CREATELOGIN", "CREATEROLE"}, + }, + { + stmt: "CREAT", + expectedCompletions: []string{"CREATE", "CREATEDB", "CREATELOGIN", "CREATEROLE"}, + }, + { + stmt: "creat ", + expectedCompletions: []string{}, + }, + { + stmt: "SHOW CREAT", + expectedCompletions: []string{"CREATE", "CREATEDB", "CREATELOGIN", "CREATEROLE"}, + }, + { + stmt: "show creat", + expectedCompletions: []string{"CREATE", "CREATEDB", "CREATELOGIN", "CREATEROLE"}, + }, + { + stmt: "se", + expectedCompletions: []string{ + "SEARCH", "SECOND", "SELECT", "SEQUENCE", "SEQUENCES", + "SERIALIZABLE", "SERVER", "SESSION", "SESSIONS", "SESSION_USER", + "SET", "SETS", "SETTING", "SETTINGS", + }, + }, + { + stmt: "sel", + expectedCompletions: []string{"SELECT"}, + }, + { + stmt: "create ta", + expectedCompletions: []string{"TABLE", "TABLES", "TABLESPACE"}, + }, + { + stmt: "create ta", + expectedCompletions: []string{"CREATE"}, + offset: 3, + }, + { + stmt: "select", + expectedCompletions: []string{"SELECT"}, + offset: 2, + }, + { + stmt: "select ", + expectedCompletions: []string{}, + offset: 7, + }, + { + stmt: "你好,我的名字是鲍勃 SELECT", + expectedCompletions: []string{"你好,我的名字是鲍勃"}, + offset: 2, + }, + { + stmt: "你好,我的名字是鲍勃 SELECT", + expectedCompletions: []string{}, + offset: 11, + }, + { + stmt: "你好,我的名字是鲍勃 SELECT", + expectedCompletions: []string{"SELECT"}, + offset: 12, + }, + } + for _, tc := range tests { + offset := tc.offset + if tc.offset == 0 { + offset = len(tc.stmt) + } + completions, err := runShowCompletions(tc.stmt, offset) + if err != nil { + t.Error(err) + } + if !(len(completions) == 0 && len(tc.expectedCompletions) == 0) && + !reflect.DeepEqual(completions, tc.expectedCompletions) { + t.Errorf("expected %v, got %v", tc.expectedCompletions, completions) + } + } +} diff --git a/pkg/sql/delegate/show_syntax.go b/pkg/sql/delegate/show_syntax.go index 183f4b5fb99a..fccfaf95ae94 100644 --- a/pkg/sql/delegate/show_syntax.go +++ b/pkg/sql/delegate/show_syntax.go @@ -65,5 +65,6 @@ func (d *delegator) delegateShowSyntax(n *tree.ShowSyntax) (tree.Statement, erro nil, /* reportErr */ ) query.WriteByte(')') + return parse(query.String()) } diff --git a/pkg/sql/logictest/testdata/logic_test/show_completions b/pkg/sql/logictest/testdata/logic_test/show_completions new file mode 100644 index 000000000000..d29214442090 --- /dev/null +++ b/pkg/sql/logictest/testdata/logic_test/show_completions @@ -0,0 +1,61 @@ +query T +show completions at offset 1 for 'select 1' +---- +SELECT + +query T +show completions at offset 7 for 'select 1' +---- +· + +query T +show completions at offset 7 for 'select 2' +---- +· + +query T +show completions at offset 10 for 'select * fro' +---- +FRO + +query T +show completions at offset 11 for 'select * fro' +---- +FRO + +query T +show completions at offset 12 for 'select * fro' +---- +FROM + +query T +show completions at offset 10 for 'select * from' +---- +FROM + +query T +show completions at offset 11 for 'select * from' +---- +FROM + +# This case doesn't really make sense - completing this as SELECT doesn't +# really make sense but we'll need to add more complex logic to determine +# whether our SQL token is a string const. +# However we do want to test this so we can ensure we handle escaped strings. +query T +show completions at offset 4 for e'\'se\''; +---- +SEARCH +SECOND +SELECT +SEQUENCE +SEQUENCES +SERIALIZABLE +SERVER +SESSION +SESSIONS +SESSION_USER +SET +SETS +SETTING +SETTINGS diff --git a/pkg/sql/parser/scanner.go b/pkg/sql/parser/scanner.go index 98c4db72cb3c..595d823b5a03 100644 --- a/pkg/sql/parser/scanner.go +++ b/pkg/sql/parser/scanner.go @@ -56,6 +56,21 @@ func Tokens(sql string) (tokens []TokenString, ok bool) { return tokens, true } +// TokensIgnoreErrors decomposes the input into lexical tokens and +// ignores errors. +func TokensIgnoreErrors(sql string) (tokens []TokenString) { + s := makeScanner(sql) + for { + var lval = &sqlSymType{} + s.Scan(lval) + if lval.ID() == 0 { + break + } + tokens = append(tokens, TokenString{TokenID: lval.ID(), Str: lval.Str()}) + } + return tokens +} + // TokenString is the unit value returned by Tokens. type TokenString struct { TokenID int32 diff --git a/pkg/sql/parser/sql.y b/pkg/sql/parser/sql.y index 3215dc3faff1..9bed9718a528 100644 --- a/pkg/sql/parser/sql.y +++ b/pkg/sql/parser/sql.y @@ -764,7 +764,7 @@ func (u *sqlSymUnion) setVar() *tree.SetVar { %token CACHE CANCEL CANCELQUERY CASCADE CASE CAST CBRT CHANGEFEED CHAR %token CHARACTER CHARACTERISTICS CHECK CLOSE %token CLUSTER COALESCE COLLATE COLLATION COLUMN COLUMNS COMMENT COMMENTS COMMIT -%token COMMITTED COMPACT COMPLETE CONCAT CONCURRENTLY CONFIGURATION CONFIGURATIONS CONFIGURE +%token COMMITTED COMPACT COMPLETE COMPLETIONS CONCAT CONCURRENTLY CONFIGURATION CONFIGURATIONS CONFIGURE %token CONFLICT CONNECTION CONSTRAINT CONSTRAINTS CONTAINS CONTROLCHANGEFEED CONTROLJOB %token CONVERSION CONVERT COPY COVERING CREATE CREATEDB CREATELOGIN CREATEROLE %token CROSS CSV CUBE CURRENT CURRENT_CATALOG CURRENT_DATE CURRENT_SCHEMA @@ -1091,6 +1091,7 @@ func (u *sqlSymUnion) setVar() *tree.SetVar { %type show_zone_stmt %type show_schedules_stmt %type show_full_scans_stmt +%type show_completions_stmt %type statements_or_queries @@ -4911,6 +4912,7 @@ show_stmt: | show_last_query_stats_stmt | show_full_scans_stmt | show_default_privileges_stmt // EXTEND WITH HELP: SHOW DEFAULT PRIVILEGES +| show_completions_stmt // Cursors are not yet supported by CockroachDB. CLOSE ALL is safe to no-op // since there will be no open cursors. @@ -5575,6 +5577,16 @@ show_syntax_stmt: } | SHOW SYNTAX error // SHOW HELP: SHOW SYNTAX +show_completions_stmt: + SHOW COMPLETIONS AT OFFSET ICONST FOR SCONST + { + /* SKIP DOC */ + $$.val = &tree.ShowCompletions{ + Statement: $7, + Offset: $5.numVal(), + } + } + show_last_query_stats_stmt: SHOW LAST QUERY STATISTICS query_stats_cols { @@ -13112,6 +13124,7 @@ unreserved_keyword: | COMMITTED | COMPACT | COMPLETE +| COMPLETIONS | CONFLICT | CONFIGURATION | CONFIGURATIONS diff --git a/pkg/sql/sem/tree/show.go b/pkg/sql/sem/tree/show.go index 51324b69b6eb..bcfe1159099d 100644 --- a/pkg/sql/sem/tree/show.go +++ b/pkg/sql/sem/tree/show.go @@ -885,3 +885,17 @@ func (n *ShowDefaultPrivileges) Format(ctx *FmtCtx) { ctx.WriteString("FOR ALL ROLES ") } } + +type ShowCompletions struct { + Statement string + Offset *NumVal +} + +func (s ShowCompletions) Format(ctx *FmtCtx) { + ctx.WriteString("SHOW COMPLETIONS AT OFFSET ") + s.Offset.Format(ctx) + ctx.WriteString(" FOR ") + ctx.WriteString(lexbase.EscapeSQLString(s.Statement)) +} + +var _ Statement = &ShowCompletions{} diff --git a/pkg/sql/sem/tree/stmt.go b/pkg/sql/sem/tree/stmt.go index cadd103b346d..548500edb916 100644 --- a/pkg/sql/sem/tree/stmt.go +++ b/pkg/sql/sem/tree/stmt.go @@ -1570,6 +1570,15 @@ func (*ShowDefaultPrivileges) StatementType() StatementType { return TypeDML } // StatementTag returns a short string identifying the type of statement. func (*ShowDefaultPrivileges) StatementTag() string { return "SHOW DEFAULT PRIVILEGES" } +// StatementReturnType implements the Statement interface. +func (*ShowCompletions) StatementReturnType() StatementReturnType { return Rows } + +// StatementType implements the Statement interface. +func (*ShowCompletions) StatementType() StatementType { return TypeDML } + +// StatementTag returns a short string identifying the type of statement. +func (*ShowCompletions) StatementTag() string { return "SHOW COMPLETIONS" } + // StatementReturnType implements the Statement interface. func (*Split) StatementReturnType() StatementReturnType { return Rows } @@ -1788,6 +1797,7 @@ func (n *ShowVar) String() string { return AsString(n) } func (n *ShowZoneConfig) String() string { return AsString(n) } func (n *ShowFingerprints) String() string { return AsString(n) } func (n *ShowDefaultPrivileges) String() string { return AsString(n) } +func (n *ShowCompletions) String() string { return AsString(n) } func (n *Split) String() string { return AsString(n) } func (n *StreamIngestion) String() string { return AsString(n) } func (n *Unsplit) String() string { return AsString(n) }