From 960967c8a68917bdd0e7db17090de93ccf8d223e Mon Sep 17 00:00:00 2001 From: eelke Date: Thu, 22 Oct 2026 17:52:37 +0200 Subject: [PATCH] The editor now has context aware completions --- core/CMakeLists.txt | 2 + core/SqlContext.cpp | 538 ++++++++++++++++++ core/SqlContext.h | 70 +++ docs/databasewindow/index.rst | 22 + pglab/CMakeLists.txt | 1 + pglab/codeeditor/CodeEditor.cpp | 131 +++++ pglab/codeeditor/CodeEditor.h | 16 + pglab/codeeditor/CompletionPopup.cpp | 112 ++++ pglab/codeeditor/CompletionPopup.h | 40 ++ pglab/querytool/QueryTool.cpp | 31 + pglab/querytool/QueryTool.h | 4 + pglablib/CMakeLists.txt | 2 + pglablib/SqlCompletionProvider.cpp | 430 ++++++++++++++ pglablib/SqlCompletionProvider.h | 69 +++ .../sql-completion-8d3f1a6b2c4e7f90.yaml | 6 + tests/pglabtests/CMakeLists.txt | 2 + .../pglabtests/tst_SqlCompletionProvider.cpp | 237 ++++++++ tests/pglabtests/tst_SqlContext.cpp | 271 +++++++++ 18 files changed, 1984 insertions(+) create mode 100644 core/SqlContext.cpp create mode 100644 core/SqlContext.h create mode 100644 pglab/codeeditor/CompletionPopup.cpp create mode 100644 pglab/codeeditor/CompletionPopup.h create mode 100644 pglablib/SqlCompletionProvider.cpp create mode 100644 pglablib/SqlCompletionProvider.h create mode 100644 releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml create mode 100644 tests/pglabtests/tst_SqlCompletionProvider.cpp create mode 100644 tests/pglabtests/tst_SqlContext.cpp diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index ec63cf3..782a87c 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,11 +1,13 @@ add_library(core STATIC my_boost_assert_handler.cpp SqlLexer.cpp + SqlContext.cpp CsvWriter.cpp BackupFormatModel.cpp ExplainTreeModelItem.cpp jsoncpp.cpp SqlLexer.h + SqlContext.h ScopeGuard.h CsvWriter.h BackupFormatModel.h diff --git a/core/SqlContext.cpp b/core/SqlContext.cpp new file mode 100644 index 0000000..5a30d03 --- /dev/null +++ b/core/SqlContext.cpp @@ -0,0 +1,538 @@ +#include "SqlContext.h" +#include "SqlLexer.h" +#include +#include + +namespace { + +struct Tok { + int start = 0; + int end = 0; + BasicTokenType type = BasicTokenType::None; + QString text; + QString upper; // only filled for Symbol tokens + + bool isIdent() const + { + return type == BasicTokenType::QuotedIdentifier + || (type == BasicTokenType::Symbol && !text.isEmpty() && !text[0].isDigit()); + } + bool isSelf(QChar c) const { return type == BasicTokenType::Self && text.size() == 1 && text[0] == c; } + bool isKeyword(const char *kw) const { return type == BasicTokenType::Symbol && upper == QLatin1String(kw); } +}; + +const QSet &reservedWords() +{ + static const QSet words = { + "SELECT", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "FETCH", "FOR", + "UNION", "INTERSECT", "EXCEPT", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", + "OUTER", "ON", "USING", "SET", "RETURNING", "VALUES", "WINDOW", "AS", "LATERAL", "WITH", + "INSERT", "INTO", "UPDATE", "DELETE", "ONLY", "TABLESAMPLE" + }; + return words; +} + +bool isReserved(const Tok &t) +{ + return t.type == BasicTokenType::Symbol && reservedWords().contains(t.upper); +} + +bool isSetOperator(const Tok &t) +{ + return t.isKeyword("UNION") || t.isKeyword("INTERSECT") || t.isKeyword("EXCEPT"); +} + +QString unquoteIdent(const QString &text, bool "ed) +{ + quoted = false; + if (text.startsWith('"')) { + quoted = true; + QString s = text.mid(1); + if (s.endsWith('"')) + s.chop(1); + s.replace(QStringLiteral("\"\""), QStringLiteral("\"")); + return s; + } + return text; +} + +// Name as used for matching against the catalog +QString normalizedIdent(const Tok &t, bool *quotedOut = nullptr) +{ + bool quoted; + QString s = unquoteIdent(t.text, quoted); + if (quotedOut) + *quotedOut = quoted; + return quoted ? s : s.toLower(); +} + +std::vector tokenize(const QString &text) +{ + std::vector toks; + SqlLexer lexer(text, LexerState::Null); + for (;;) { + SqlToken t = lexer.nextBasicToken(); + if (t.tokenType == BasicTokenType::End) + break; + if (t.length <= 0 && t.tokenType == BasicTokenType::None) + break; + if (t.tokenType == BasicTokenType::WhiteSpace || t.tokenType == BasicTokenType::NewLine) + continue; + Tok tok; + tok.start = t.startPos; + tok.end = t.startPos + t.length; + tok.type = t.tokenType; + tok.text = text.mid(t.startPos, t.length); + if (tok.type == BasicTokenType::Symbol) + tok.upper = tok.text.toUpper(); + toks.push_back(std::move(tok)); + } + return toks; +} + +bool isUnterminatedQuote(const QString &s) +{ + return s.startsWith('"') && (s.count('"') % 2 == 1); +} + +bool isRelationClause(SqlClause c) +{ + return c == SqlClause::From || c == SqlClause::Join || c == SqlClause::Update || c == SqlClause::InsertInto; +} + +// Relations of a parent query are visible in sub queries inside these clauses +bool parentVisible(SqlClause c) +{ + switch (c) { + case SqlClause::Select: + case SqlClause::Where: + case SqlClause::On: + case SqlClause::GroupBy: + case SqlClause::OrderBy: + case SqlClause::Having: + case SqlClause::Set: + case SqlClause::Returning: + case SqlClause::Values: + case SqlClause::InsertInto: + return true; + default: + return false; + } +} + +class Analyzer { +public: + using Toks = std::vector; + + explicit Analyzer(const Toks &s) : S(s) {} + + struct Level { + int begin = 0; // first token index inside the level + int end = 0; // one past last token index inside the level + int open = -1; // index of "(" or -1 for statement level + }; + + // Direct tokens of level: nested groups are skipped, but the "(" and ")" markers are kept + std::vector directTokens(const Level &lv) const + { + std::vector d; + int depth = 0; + for (int i = lv.begin; i < lv.end; ++i) { + if (S[i].isSelf('(')) { + if (depth == 0) + d.push_back(i); + ++depth; + } + else if (S[i].isSelf(')')) { + if (depth > 0) + --depth; + if (depth == 0) + d.push_back(i); + } + else if (depth == 0) + d.push_back(i); + } + return d; + } + + struct Segment { + std::vector d; // direct tokens of the segment + int cursor = 0; // position in d of first token at or after the cursor + }; + + // The part of the level between set operators that contains token index 'cursorIdx' + Segment segment(const Level &lv, int cursorIdx) const + { + std::vector d = directTokens(lv); + size_t dc = 0; + while (dc < d.size() && d[dc] < cursorIdx) + ++dc; + size_t b = 0; + for (size_t i = 0; i < dc; ++i) + if (isSetOperator(S[d[i]])) + b = i + 1; + size_t e = d.size(); + for (size_t i = dc; i < d.size(); ++i) + if (isSetOperator(S[d[i]])) { + e = i; + break; + } + Segment seg; + seg.d.assign(d.begin() + b, d.begin() + e); + seg.cursor = static_cast(dc - b); + return seg; + } + + SqlClause clauseAt(const Segment &seg) const + { + SqlClause clause = SqlClause::None; + for (int i = 0; i < seg.cursor; ++i) { + const Tok &t = S[seg.d[i]]; + if (t.type != BasicTokenType::Symbol) + continue; + const QString &k = t.upper; + if (k == "SELECT") clause = SqlClause::Select; + else if (k == "FROM") clause = SqlClause::From; + else if (k == "JOIN") clause = SqlClause::Join; + else if (k == "ON" || k == "USING") clause = SqlClause::On; + else if (k == "WHERE") clause = SqlClause::Where; + else if (k == "GROUP") clause = SqlClause::GroupBy; + else if (k == "ORDER") clause = SqlClause::OrderBy; + else if (k == "HAVING") clause = SqlClause::Having; + else if (k == "SET") clause = SqlClause::Set; + else if (k == "UPDATE") clause = SqlClause::Update; + else if (k == "INTO") clause = SqlClause::InsertInto; + else if (k == "RETURNING") clause = SqlClause::Returning; + else if (k == "VALUES") clause = SqlClause::Values; + else if (k == "LIMIT" || k == "OFFSET" || k == "FETCH" || k == "WINDOW" || k == "WITH") + clause = SqlClause::None; + } + return clause; + } + + // True when the token before the cursor allows a new relation name + bool relationExpected(const Segment &seg) const + { + if (seg.cursor == 0) + return true; + const Tok &p = S[seg.d[seg.cursor - 1]]; + return p.type == BasicTokenType::Comma + || p.isSelf('(') + || p.isKeyword("FROM") || p.isKeyword("JOIN") || p.isKeyword("UPDATE") + || p.isKeyword("INTO") || p.isKeyword("ONLY") || p.isKeyword("LATERAL"); + } + + // Parse one relation item starting at position d in seg; returns the position after it + int parseItem(const Segment &seg, int d, std::vector &out) const + { + const int n = static_cast(seg.d.size()); + auto tok = [&](int i) -> const Tok & { return S[seg.d[i]]; }; + while (d < n && (tok(d).isKeyword("ONLY") || tok(d).isKeyword("LATERAL"))) + ++d; + if (d >= n) + return d; + + RelationRef ref; + if (tok(d).isSelf('(')) { + ref.derived = true; + ++d; + if (d < n && tok(d).isSelf(')')) + ++d; + } + else if (tok(d).isIdent() && !isReserved(tok(d))) { + std::vector parts{ &tok(d) }; + ++d; + while (d + 1 < n && tok(d).isSelf('.') && tok(d + 1).isIdent()) { + parts.push_back(&tok(d + 1)); + d += 2; + } + ref.name = normalizedIdent(*parts.back(), &ref.quoted); + if (parts.size() >= 2) + ref.schema = normalizedIdent(*parts[parts.size() - 2]); + } + else + return d; + + if (d < n && tok(d).isKeyword("AS")) + ++d; + if (d < n && tok(d).isIdent() && !isReserved(tok(d))) { + ref.alias = normalizedIdent(tok(d)); + ++d; + } + if (d < n && tok(d).isSelf('(') && ref.derived == false) { + // alias column list or INSERT column list, leave to caller; skip only after an alias + if (!ref.alias.isEmpty() && d + 1 < n && tok(d + 1).isSelf(')')) + d += 2; + } + if (ref.derived) { + if (ref.alias.isEmpty()) + return d; + ref.name = ref.alias; + } + out.push_back(std::move(ref)); + return d; + } + + void collectRelations(const Segment &seg, std::vector &out) const + { + const int n = static_cast(seg.d.size()); + bool hasSelect = false; + for (int i = 0; i < n; ++i) + if (S[seg.d[i]].isKeyword("SELECT")) + hasSelect = true; + + bool inFrom = false; + int d = 0; + while (d < n) { + const Tok &t = S[seg.d[d]]; + if (t.isKeyword("FROM") || t.isKeyword("JOIN") || t.isKeyword("UPDATE")) { + inFrom = true; + d = parseItem(seg, d + 1, out); + } + else if (t.isKeyword("INTO") && d > 0 && S[seg.d[d - 1]].isKeyword("INSERT")) { + inFrom = true; + if (!hasSelect) + d = parseItem(seg, d + 1, out); + else { + std::vector dummy; + d = parseItem(seg, d + 1, dummy); + } + } + else if (t.type == BasicTokenType::Comma && inFrom) { + d = parseItem(seg, d + 1, out); + } + else { + if (t.type == BasicTokenType::Symbol && reservedWords().contains(t.upper)) + inFrom = false; + ++d; + } + } + } + + // Names of common table expressions defined by the WITH clause of the statement + void collectCtes(std::vector &out) const + { + Level top{ 0, static_cast(S.size()), -1 }; + std::vector d = directTokens(top); + size_t i = 0; + if (d.empty() || !S[d[0]].isKeyword("WITH")) + return; + i = 1; + if (i < d.size() && S[d[i]].isKeyword("RECURSIVE")) + ++i; + while (i < d.size()) { + const Tok &nameTok = S[d[i]]; + if (!nameTok.isIdent() || isReserved(nameTok)) + return; + RelationRef ref; + ref.name = normalizedIdent(nameTok, &ref.quoted); + ref.derived = true; + ++i; + if (i < d.size() && S[d[i]].isSelf('(')) { // column list + ++i; + if (i < d.size() && S[d[i]].isSelf(')')) + ++i; + } + out.push_back(std::move(ref)); + if (i >= d.size() || !S[d[i]].isKeyword("AS")) + return; + ++i; + while (i < d.size() && (S[d[i]].isKeyword("NOT") || S[d[i]].isKeyword("MATERIALIZED"))) + ++i; + if (i < d.size() && S[d[i]].isSelf('(')) { + ++i; + if (i < d.size() && S[d[i]].isSelf(')')) + ++i; + } + else + return; + if (i < d.size() && S[d[i]].type == BasicTokenType::Comma) + ++i; + else + return; + } + } + + const Toks &S; +}; + +} // namespace + +SqlContext analyzeSqlContext(const QString &text, int cursorPos) +{ + SqlContext ctx; + cursorPos = std::clamp(cursorPos, 0, static_cast(text.size())); + ctx.prefixStart = cursorPos; + + // An unterminated quoted identifier before the cursor would swallow the remaining text + QString work = text; + { + std::vector head = tokenize(text.left(cursorPos)); + if (!head.empty() && head.back().end == cursorPos && isUnterminatedQuote(head.back().text)) + work.insert(cursorPos, QLatin1Char('"')); + } + + std::vector T = tokenize(work); + + // Inside comments and strings nothing is completed + for (const Tok &t : T) { + if (t.start < cursorPos && cursorPos <= t.end) { + switch (t.type) { + case BasicTokenType::Comment: + case BasicTokenType::BlockComment: + case BasicTokenType::OpenBlockComment: + case BasicTokenType::QuotedString: + case BasicTokenType::DollarQuote: + return ctx; + default: + break; + } + } + else if (t.type == BasicTokenType::OpenBlockComment && t.start < cursorPos) + return ctx; + } + + // Prefix token + int prefIdx = -1; + int cut = static_cast(T.size()); + for (int i = 0; i < static_cast(T.size()); ++i) { + const Tok &t = T[i]; + if (t.start < cursorPos && cursorPos <= t.end && t.isIdent()) { + prefIdx = i; + cut = i; + break; + } + if (t.start >= cursorPos) { + cut = i; + break; + } + } + if (prefIdx >= 0) { + const Tok &t = T[prefIdx]; + QString typed = text.mid(t.start, cursorPos - t.start); + ctx.prefixStart = t.start; + ctx.prefixLen = cursorPos - t.start; + ctx.prefix = unquoteIdent(typed, ctx.prefixWasQuoted); + } + + // Qualifier: [schema .] qualifier . + int qualStart = cut; + if (cut >= 2 && T[cut - 1].isSelf('.') && T[cut - 2].isIdent()) { + ctx.qualifier = normalizedIdent(T[cut - 2]); + qualStart = cut - 2; + if (cut >= 4 && T[cut - 3].isSelf('.') && T[cut - 4].isIdent()) { + ctx.qualifierSchema = normalizedIdent(T[cut - 4]); + qualStart = cut - 4; + } + } + const int removedEnd = prefIdx >= 0 ? cut + 1 : cut; // one past last removed token + + // Statement bounds + int sb = 0; + for (int i = qualStart - 1; i >= 0; --i) + if (T[i].isSelf(';')) { + sb = i + 1; + break; + } + int se = static_cast(T.size()); + for (int i = removedEnd; i < static_cast(T.size()); ++i) + if (T[i].isSelf(';')) { + se = i; + break; + } + + std::vector S; + for (int i = sb; i < se; ++i) + if (i < qualStart || i >= removedEnd) + S.push_back(T[i]); + const int cur = qualStart - sb; // index in S of first token after the cursor + + Analyzer an(S); + + // Enclosing paren groups + std::vector opens; + for (int i = 0; i < cur; ++i) { + if (S[i].isSelf('(')) + opens.push_back(i); + else if (S[i].isSelf(')') && !opens.empty()) + opens.pop_back(); + } + auto levelFor = [&](int open) { + Analyzer::Level lv; + lv.open = open; + lv.begin = open + 1; + lv.end = static_cast(S.size()); + if (open >= 0) { + int depth = 1; + for (int i = open + 1; i < static_cast(S.size()); ++i) { + if (S[i].isSelf('(')) ++depth; + else if (S[i].isSelf(')') && --depth == 0) { + lv.end = i; + break; + } + } + } + else + lv.begin = 0; + return lv; + }; + + // Levels from innermost to outermost + std::vector levels; + for (int i = static_cast(opens.size()) - 1; i >= 0; --i) + levels.push_back(levelFor(opens[i])); + levels.push_back(levelFor(-1)); + + SqlClause clause = SqlClause::None; + bool relExpected = false; + bool clauseResolved = false; + bool visible = true; + for (size_t li = 0; li < levels.size(); ++li) { + const Analyzer::Level &lv = levels[li]; + const int cursorInLevel = li == 0 ? cur : levels[li - 1].open; + Analyzer::Segment seg = an.segment(lv, cursorInLevel); + const SqlClause lc = an.clauseAt(seg); + + // a level without clause keyword is a parenthesised expression and inherits the clause of its parent + if (!clauseResolved && lc != SqlClause::None) { + clause = lc; + relExpected = isRelationClause(lc) && an.relationExpected(seg); + clauseResolved = true; + } + if (li >= 1) + visible = visible && parentVisible(lc); + if (visible) + an.collectRelations(seg, ctx.relations); + } + an.collectCtes(ctx.relations); + + ctx.clause = clause; + const bool hasQualifier = !ctx.qualifier.isEmpty(); + switch (clause) { + case SqlClause::Select: + case SqlClause::On: + case SqlClause::Where: + case SqlClause::GroupBy: + case SqlClause::OrderBy: + case SqlClause::Having: + case SqlClause::Set: + case SqlClause::Returning: + case SqlClause::Values: + ctx.kind = hasQualifier ? CompletionKind::QualifiedColumn : CompletionKind::Column; + break; + case SqlClause::From: + case SqlClause::Join: + case SqlClause::Update: + case SqlClause::InsertInto: + if (hasQualifier) + ctx.kind = CompletionKind::Relation; + else if (relExpected) + ctx.kind = clause == SqlClause::Join ? CompletionKind::JoinTarget : CompletionKind::Relation; + else if (clause == SqlClause::InsertInto && !levels.empty() && levels[0].open >= 0) + ctx.kind = CompletionKind::Column; + break; + default: + break; + } + return ctx; +} diff --git a/core/SqlContext.h b/core/SqlContext.h new file mode 100644 index 0000000..323e844 --- /dev/null +++ b/core/SqlContext.h @@ -0,0 +1,70 @@ +#ifndef SQLCONTEXT_H +#define SQLCONTEXT_H + +#include +#include + +/** + * Kind of names that are expected at the cursor position. + */ +enum class CompletionKind { + None, + Relation, // schema, table or view name + Column, // column name of the relations in scope + QualifiedColumn, // column after "alias." or "table." + Schema, + Function, + JoinTarget // complete "table alias ON ..." proposal after JOIN +}; + +/** + * Part of the statement the cursor is in. + */ +enum class SqlClause { + None, + Select, + From, + Join, + On, + Where, + GroupBy, + OrderBy, + Having, + Set, + Update, + InsertInto, + Returning, + Values +}; + +/** + * An item of a FROM clause (or UPDATE/INSERT target, CTE, derived table). + * Names are normalised: unquoted identifiers are folded to lower case. + */ +struct RelationRef { + QString schema; + QString name; // may be a CTE name or the alias of a derived table + QString alias; + bool quoted = false; // name was written as quoted identifier + bool derived = false; // CTE or sub query, columns are not in the catalog +}; + +struct SqlContext { + CompletionKind kind = CompletionKind::None; + SqlClause clause = SqlClause::None; + QString prefix; // identifier text typed so far (without opening quote) + int prefixStart = 0; // replace range in document + int prefixLen = 0; + QString qualifier; // text before '.', if any + QString qualifierSchema; // for schema.table. + std::vector relations; // relations in scope, innermost query first + bool prefixWasQuoted = false; +}; + +/** + * Tolerant analysis of possibly incomplete SQL around the cursor. + * @param cursorPos position in text (between characters) where completion is requested. + */ +SqlContext analyzeSqlContext(const QString &text, int cursorPos); + +#endif // SQLCONTEXT_H diff --git a/docs/databasewindow/index.rst b/docs/databasewindow/index.rst index b2fb251..32c9fd5 100644 --- a/docs/databasewindow/index.rst +++ b/docs/databasewindow/index.rst @@ -25,6 +25,28 @@ and opened with :kbd:`Ctrl+O` or :menuselection:`File --> Open query`. You can a drag and drop a bunch of files onto the window and they will be opened in seperate query tabs. +Completion +========== + +The query editor proposes names while you type, or when you press :kbd:`Ctrl+Space`. +After ``FROM``, ``JOIN`` and ``UPDATE`` schemas and tables are proposed, +and after ``schema.`` the relations of that schema. In other places the columns of the +tables in the query are proposed, also when the ``FROM`` clause comes after the cursor. +Type ``alias.`` or ``table.`` to only see the columns of that relation. +Use :kbd:`Up`/:kbd:`Down` to select, :kbd:`Enter` or :kbd:`Tab` to accept and :kbd:`Esc` to close the list. + +Names that need quotes, for instance reserved words or names with capitals, are inserted +quoted. When a column name occurs in more than one table of the query, every +occurrence is listed with its table or alias in bold and accepting it inserts the +name qualified, for example ``o.id``. + +After ``JOIN`` the list starts with complete join clauses such as +``customers c ON o.customer_id = c.id``, deduced from the foreign keys of the +tables already in the query. Both directions and multi-column keys are supported +and the generated alias never clashes with a name already in use. + +Functions are proposed once you have typed the start of their name. + Explain ======= diff --git a/pglab/CMakeLists.txt b/pglab/CMakeLists.txt index 724530b..10362a1 100644 --- a/pglab/CMakeLists.txt +++ b/pglab/CMakeLists.txt @@ -25,6 +25,7 @@ set(PGLAB_SOURCES catalog/widgets/DependantsPage.cpp catalog/widgets/TriggerPage.cpp codeeditor/CodeEditor.cpp + codeeditor/CompletionPopup.cpp codeeditor/EditorGutter.cpp codeeditor/GutterPainter.cpp crud/CrudModel.cpp diff --git a/pglab/codeeditor/CodeEditor.cpp b/pglab/codeeditor/CodeEditor.cpp index 0107c7f..31f648c 100644 --- a/pglab/codeeditor/CodeEditor.cpp +++ b/pglab/codeeditor/CodeEditor.cpp @@ -1,5 +1,6 @@ #include "CodeEditor.h" #include "EditorGutter.h" +#include #include #include #include @@ -124,9 +125,125 @@ bool CodeEditor::lineHasError(int blockNumber) const return errorLines.count(blockNumber) > 0; } +void CodeEditor::setCompletionCallback(EditorCompletionCallback callback) +{ + m_completionCallback = std::move(callback); + if (!m_completionCallback) + hideCompletions(); +} + +void CodeEditor::showCompletions() +{ + updateCompletions(true); +} + +void CodeEditor::hideCompletions() +{ + if (m_completionPopup) + m_completionPopup->hide(); +} + +bool CodeEditor::updateCompletions(bool explicitRequest) +{ + if (!m_completionCallback) { + hideCompletions(); + return false; + } + const int pos = textCursor().position(); + EditorCompletionResult result = m_completionCallback(toPlainText(), pos); + bool show = !result.items.empty(); + if (show && !explicitRequest) { + // do not bother the user while nothing identifier like has been typed + if (result.replaceLen == 0 && !result.hasQualifier) + show = false; + // the only candidate is what has been typed already + else if (result.items.size() == 1 && result.replaceLen > 0) { + QTextCursor c = textCursor(); + c.setPosition(result.replaceStart); + c.setPosition(result.replaceStart + result.replaceLen, QTextCursor::KeepAnchor); + if (c.selectedText() == result.items[0].insertText) + show = false; + } + } + if (!show) { + hideCompletions(); + return false; + } + + if (!m_completionPopup) { + m_completionPopup = new CompletionPopup(this); + connect(m_completionPopup, &CompletionPopup::itemAccepted, this, &CodeEditor::acceptCompletion); + } + m_completionResult = std::move(result); + m_completionPopup->setFont(font()); + m_completionPopup->setItems(m_completionResult.items); + + QTextCursor c = textCursor(); + c.setPosition(m_completionResult.replaceStart); + QRect r = cursorRect(c); + m_completionPopup->showAt(viewport()->mapToGlobal(r.bottomLeft())); + return true; +} + +void CodeEditor::acceptCompletion(int row) +{ + if (row < 0 || row >= static_cast(m_completionResult.items.size())) + return; + const QString text = m_completionResult.items[row].insertText; + QTextCursor c = textCursor(); + c.beginEditBlock(); + c.setPosition(m_completionResult.replaceStart); + c.setPosition(m_completionResult.replaceStart + m_completionResult.replaceLen, QTextCursor::KeepAnchor); + c.insertText(text); + c.endEditBlock(); + setTextCursor(c); + hideCompletions(); +} + +void CodeEditor::focusOutEvent(QFocusEvent *e) +{ + hideCompletions(); + QPlainTextEdit::focusOutEvent(e); +} + +void CodeEditor::mousePressEvent(QMouseEvent *e) +{ + hideCompletions(); + QPlainTextEdit::mousePressEvent(e); +} + void CodeEditor::keyPressEvent(QKeyEvent *e) { auto k = e->key(); + const bool popupVisible = m_completionPopup && m_completionPopup->isVisible(); + if (popupVisible) { + switch (k) { + case Qt::Key_Down: + m_completionPopup->moveSelection(1); + return; + case Qt::Key_Up: + m_completionPopup->moveSelection(-1); + return; + case Qt::Key_PageDown: + m_completionPopup->moveSelection(10); + return; + case Qt::Key_PageUp: + m_completionPopup->moveSelection(-10); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + case Qt::Key_Tab: + acceptCompletion(m_completionPopup->currentRow()); + return; + case Qt::Key_Escape: + hideCompletions(); + return; + } + } + if (k == Qt::Key_Space && e->modifiers().testFlag(Qt::ControlModifier)) { + showCompletions(); + return; + } switch (k) { case Qt::Key_Tab: if (indentSelection(true)) @@ -155,6 +272,20 @@ void CodeEditor::keyPressEvent(QKeyEvent *e) break; } QPlainTextEdit::keyPressEvent(e); + + if (m_completionCallback) { + if (popupVisible) { + if (k == Qt::Key_Backspace || k == Qt::Key_Delete || !e->text().isEmpty()) + updateCompletions(false); + else if (k == Qt::Key_Left || k == Qt::Key_Right || k == Qt::Key_Home || k == Qt::Key_End) + hideCompletions(); + } + else if (!e->text().isEmpty() && !e->modifiers().testFlag(Qt::ControlModifier)) { + const QChar ch = e->text()[0]; + if (ch.isLetterOrNumber() || ch == QLatin1Char('_') || ch == QLatin1Char('.') || ch == QLatin1Char('"')) + updateCompletions(false); + } + } } diff --git a/pglab/codeeditor/CodeEditor.h b/pglab/codeeditor/CodeEditor.h index 4a3303d..83adde7 100644 --- a/pglab/codeeditor/CodeEditor.h +++ b/pglab/codeeditor/CodeEditor.h @@ -4,6 +4,7 @@ #include #include #include "util/Colors.h" +#include "CompletionPopup.h" /** This class adds some capabilities to QPlainTextEdit that are useful * in code editor scenarios. @@ -20,9 +21,16 @@ public: void clearErrorMarkers(); void setFont(const QFont &); void setTabSize(int chars); + + /// Install the function that supplies completions, an empty function disables completion + void setCompletionCallback(EditorCompletionCallback callback); + /// Show completions at the cursor, also when nothing has been typed yet + void showCompletions(); protected: void resizeEvent(QResizeEvent *event) override; void keyPressEvent(QKeyEvent *e) override; + void focusOutEvent(QFocusEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; signals: @@ -44,6 +52,14 @@ private: std::set errorLines; + EditorCompletionCallback m_completionCallback; + CompletionPopup *m_completionPopup = nullptr; + EditorCompletionResult m_completionResult; + + bool updateCompletions(bool explicitRequest); + void hideCompletions(); + void acceptCompletion(int row); + // Settings int m_tabSize = 0; // tabSize in characters bool m_useTab = false; diff --git a/pglab/codeeditor/CompletionPopup.cpp b/pglab/codeeditor/CompletionPopup.cpp new file mode 100644 index 0000000..d796137 --- /dev/null +++ b/pglab/codeeditor/CompletionPopup.cpp @@ -0,0 +1,112 @@ +#include "CompletionPopup.h" +#include +#include +#include +#include + +namespace { + +constexpr int DetailRole = Qt::UserRole + 1; +constexpr int AmbiguousRole = Qt::UserRole + 2; + +class CompletionDelegate : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + const QString display = opt.text; + opt.text.clear(); + const QWidget *w = opt.widget; + (w ? w->style() : QApplication::style())->drawControl(QStyle::CE_ItemViewItem, &opt, painter, w); + + const bool selected = opt.state & QStyle::State_Selected; + const QPalette &pal = opt.palette; + const QString detail = index.data(DetailRole).toString(); + const bool ambiguous = index.data(AmbiguousRole).toBool(); + + painter->save(); + painter->setPen(selected ? pal.highlightedText().color() : pal.text().color()); + QRect r = opt.rect.adjusted(4, 0, -4, 0); + painter->drawText(r, Qt::AlignVCenter | Qt::AlignLeft, display); + if (!detail.isEmpty()) { + const int x = painter->fontMetrics().horizontalAdvance(display) + 12; + QColor dim = selected ? pal.highlightedText().color() : pal.color(QPalette::Disabled, QPalette::Text); + painter->setPen(dim); + if (ambiguous) { + QFont f = painter->font(); + f.setBold(true); + painter->setFont(f); + } + painter->drawText(r.adjusted(x, 0, 0, 0), Qt::AlignVCenter | Qt::AlignLeft, QStringLiteral("\u2014 ") + detail); + } + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + QSize s = QStyledItemDelegate::sizeHint(option, index); + const QFontMetrics fm(option.font); + s.setWidth(fm.horizontalAdvance(index.data(Qt::DisplayRole).toString()) + + fm.horizontalAdvance(index.data(DetailRole).toString()) + 40); + return s; + } +}; + +} // namespace + +CompletionPopup::CompletionPopup(QWidget *parent) + : QListWidget(parent) +{ + setWindowFlags(Qt::ToolTip); + setAttribute(Qt::WA_ShowWithoutActivating); + setFocusPolicy(Qt::NoFocus); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setUniformItemSizes(true); + setItemDelegate(new CompletionDelegate(this)); + connect(this, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) { + emit itemAccepted(row(item)); + }); +} + +void CompletionPopup::setItems(const std::vector &items) +{ + clear(); + int width = 150; + const QFontMetrics fm(font()); + for (const auto &i : items) { + auto *item = new QListWidgetItem(i.display, this); + item->setData(DetailRole, i.detail); + item->setData(AmbiguousRole, i.ambiguous); + width = std::max(width, fm.horizontalAdvance(i.display) + fm.horizontalAdvance(i.detail) + 50); + } + if (!items.empty()) + setCurrentRow(0); + const int rows = static_cast(std::min(items.size(), 10)); + const int rowHeight = fm.height() + 6; + resize(std::min(width, 600), rows * rowHeight + 2 * frameWidth() + 2); +} + +void CompletionPopup::moveSelection(int delta) +{ + const int n = count(); + if (n == 0) + return; + int r = QListWidget::currentRow() + delta; + r = std::clamp(r, 0, n - 1); + setCurrentRow(r); +} + +void CompletionPopup::showAt(const QPoint &globalPos) +{ + QPoint p = globalPos; + if (auto *screen = QApplication::screenAt(globalPos)) { + const QRect avail = screen->availableGeometry(); + if (p.x() + width() > avail.right()) + p.setX(std::max(avail.left(), avail.right() - width())); + } + move(p); + show(); +} diff --git a/pglab/codeeditor/CompletionPopup.h b/pglab/codeeditor/CompletionPopup.h new file mode 100644 index 0000000..47c4768 --- /dev/null +++ b/pglab/codeeditor/CompletionPopup.h @@ -0,0 +1,40 @@ +#ifndef COMPLETIONPOPUP_H +#define COMPLETIONPOPUP_H + +#include +#include +#include + +struct EditorCompletionItem { + QString display; + QString detail; + QString insertText; + bool ambiguous = false; +}; + +struct EditorCompletionResult { + int replaceStart = 0; + int replaceLen = 0; + bool hasQualifier = false; // completing behind "name." + std::vector items; +}; + +/// Callback that determines the completions for a cursor position in the text +using EditorCompletionCallback = std::function; + +/// List shown below the cursor, it never takes keyboard focus so typing continues in the editor. +class CompletionPopup : public QListWidget { + Q_OBJECT +public: + explicit CompletionPopup(QWidget *parent); + + void setItems(const std::vector &items); + void moveSelection(int delta); + int currentRow() const { return QListWidget::currentRow(); } + void showAt(const QPoint &globalPos); + +signals: + void itemAccepted(int row); +}; + +#endif // COMPLETIONPOPUP_H diff --git a/pglab/querytool/QueryTool.cpp b/pglab/querytool/QueryTool.cpp index 7896a65..e116f38 100644 --- a/pglab/querytool/QueryTool.cpp +++ b/pglab/querytool/QueryTool.cpp @@ -16,6 +16,7 @@ #include "json/json.h" #include "OpenDatabase.h" #include "catalog/PgDatabaseCatalog.h" +#include "SqlCompletionProvider.h" #include "Pgsql_PgException.h" #include "QueryParamListController.h" #include "util.h" @@ -54,6 +55,7 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr docu connect(m_document.get(), &SqlDocument::modifiedChanged, this, &QueryTool::updateTitle); connect(m_document.get(), &SqlDocument::fileNameChanged, this, &QueryTool::updateTitle); ui->queryEdit->setAcceptDrops(false); + setupCompletion(); m_queryParamListController = new QueryParamListController(ui->paramTableView, m_context->openDatabase(), this); connect(ui->addButton, &QPushButton::clicked, m_queryParamListController, @@ -64,6 +66,35 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr docu startConnect(); } +void QueryTool::setupCompletion() +{ + connect(m_catalog.get(), &PgDatabaseCatalog::refreshed, this, [this]() { + m_completionProvider.reset(); + }); + + ui->queryEdit->setCompletionCallback([this](const QString &text, int pos) { + EditorCompletionResult result; + if (!m_catalog) + return result; + if (!m_completionProvider) + m_completionProvider = std::make_shared(buildCompletionCatalog(*m_catalog)); + + const SqlContext ctx = analyzeSqlContext(text, pos); + result.replaceStart = ctx.prefixStart; + result.replaceLen = ctx.prefixLen; + result.hasQualifier = !ctx.qualifier.isEmpty(); + for (const auto &item : m_completionProvider->complete(ctx)) { + EditorCompletionItem i; + i.display = item.display; + i.detail = item.detail; + i.insertText = item.insertText; + i.ambiguous = item.ambiguous; + result.items.push_back(std::move(i)); + } + return result; + }); +} + QueryTool::~QueryTool() { // Detach the editor from the shared document, it may be destroyed together with this tool diff --git a/pglab/querytool/QueryTool.h b/pglab/querytool/QueryTool.h index b7547ab..854c44b 100644 --- a/pglab/querytool/QueryTool.h +++ b/pglab/querytool/QueryTool.h @@ -25,6 +25,7 @@ class QTableView; class QTabWidget; class SqlSyntaxHighlighter; +class SqlCompletionProvider; class ExplainRoot; class QueryExplainModel; class PgTypeContainer; @@ -80,6 +81,9 @@ private: std::shared_ptr m_catalog; + std::shared_ptr m_completionProvider; ///< built on first use, reset when the catalog is refreshed + + void setupCompletion(); ASyncDBConnection m_dbConnection; std::unique_ptr explainModel; diff --git a/pglablib/CMakeLists.txt b/pglablib/CMakeLists.txt index 0542f05..689be13 100644 --- a/pglablib/CMakeLists.txt +++ b/pglablib/CMakeLists.txt @@ -31,6 +31,7 @@ add_library(pglablib STATIC ui/catalog/tables/TableTreeBuilder.cpp util.cpp SqlFormattingUtils.cpp + SqlCompletionProvider.cpp catalog/PgKeywordList.cpp QueryGenerator.cpp ResultSourceMapping.cpp @@ -107,6 +108,7 @@ add_library(pglablib STATIC ui/catalog/tables/TableTreeBuilder.h util.h SqlFormattingUtils.h + SqlCompletionProvider.h catalog/PgCatalogTypes.h catalog/PgKeywordList.h QueryGenerator.h diff --git a/pglablib/SqlCompletionProvider.cpp b/pglablib/SqlCompletionProvider.cpp new file mode 100644 index 0000000..3569fae --- /dev/null +++ b/pglablib/SqlCompletionProvider.cpp @@ -0,0 +1,430 @@ +#include "SqlCompletionProvider.h" +#include "SqlFormattingUtils.h" +#include "catalog/PgDatabaseCatalog.h" +#include "catalog/PgAttributeContainer.h" +#include "catalog/PgClassContainer.h" +#include "catalog/PgNamespaceContainer.h" +#include "catalog/PgProcContainer.h" +#include "catalog/PgConstraintContainer.h" +#include +#include +#include + +namespace { + +bool matchesPrefix(const QString &name, const SqlContext &ctx) +{ + return name.startsWith(ctx.prefix, ctx.prefixWasQuoted ? Qt::CaseSensitive : Qt::CaseInsensitive); +} + +bool isQueryable(char kind) +{ + return kind == 'r' || kind == 'v' || kind == 'm' || kind == 'f' || kind == 'p'; +} + +QString kindDescription(char kind) +{ + switch (kind) { + case 'v': return QStringLiteral("view"); + case 'm': return QStringLiteral("materialized view"); + case 'f': return QStringLiteral("foreign table"); + default: return QStringLiteral("table"); + } +} + +// The name used to refer to a relation in the query +QString referenceName(const RelationRef &ref) +{ + return ref.alias.isEmpty() ? ref.name : ref.alias; +} + +} // namespace + +CompletionCatalog buildCompletionCatalog(const PgDatabaseCatalog &catalog) +{ + CompletionCatalog cc; + auto namespaces = catalog.namespaces(); + auto classes = catalog.classes(); + auto attributes = catalog.attributes(); + + if (namespaces) { + for (const auto &ns : *namespaces) { + const QString name = ns.objectName(); + if (name == QLatin1String("pg_toast") || name.startsWith(QLatin1String("pg_temp")) || name.startsWith(QLatin1String("pg_toast_temp"))) + continue; + cc.schemas.push_back(name); + } + } + if (classes) { + for (const auto &cls : *classes) { + char kind = '\0'; + switch (cls.kind) { + case RelKind::Table: kind = 'r'; break; + case RelKind::View: kind = 'v'; break; + case RelKind::MaterializedView: kind = 'm'; break; + case RelKind::ForeignTable: kind = 'f'; break; + case RelKind::PartitionedTable: kind = 'p'; break; + default: break; + } + if (kind == '\0') + continue; + CompletionRelation rel; + rel.schema = cls.nsName(); + rel.name = cls.objectName(); + rel.kind = kind; + if (attributes) { + for (const auto &att : attributes->getColumnsForRelation(cls.oid())) + if (att.num > 0 && !att.isdropped) + rel.columns.push_back(att.name); + } + cc.relations.push_back(std::move(rel)); + } + } + auto procs = catalog.procs(); + if (procs) { + for (const auto &proc : *procs) { + char kind = 'f'; + if (proc.isAggregate()) kind = 'a'; + else if (proc.isWindow()) kind = 'w'; + else if (!proc.isFunction()) continue; + cc.functions.push_back({ proc.nsName(), proc.objectName(), kind }); + } + } + auto constraints = catalog.constraints(); + if (constraints && classes && attributes) { + auto columnNames = [&](Oid relid, const auto &keys) { + std::vector names; + for (auto num : keys) { + const PgAttribute *att = attributes->getByKey(PgAttribute::Key(relid, num)); + names.push_back(att ? att->name : QString()); + } + return names; + }; + for (const auto &con : *constraints) { + if (con.type != ConstraintType::ForeignKey || con.key.size() != con.fkey.size()) + continue; + const PgClass *child = classes->getByKey(con.relid); + const PgClass *parent = classes->getByKey(con.frelid); + if (!child || !parent) + continue; + CompletionForeignKey fk; + fk.childSchema = child->nsName(); + fk.childTable = child->objectName(); + fk.parentSchema = parent->nsName(); + fk.parentTable = parent->objectName(); + fk.childColumns = columnNames(con.relid, con.key); + fk.parentColumns = columnNames(con.frelid, con.fkey); + if (std::find(fk.childColumns.begin(), fk.childColumns.end(), QString()) != fk.childColumns.end() + || std::find(fk.parentColumns.begin(), fk.parentColumns.end(), QString()) != fk.parentColumns.end()) + continue; + cc.foreignKeys.push_back(std::move(fk)); + } + } + return cc; +} + +SqlCompletionProvider::SqlCompletionProvider(CompletionCatalog catalog) + : m_catalog(std::move(catalog)) +{ +} + +const CompletionRelation* SqlCompletionProvider::resolve(const RelationRef &ref) const +{ + if (ref.derived) + return nullptr; + if (!ref.schema.isEmpty()) { + for (const auto &r : m_catalog.relations) + if (r.name == ref.name && r.schema == ref.schema) + return &r; + return nullptr; + } + for (const auto &schema : m_catalog.searchPath) + for (const auto &r : m_catalog.relations) + if (r.name == ref.name && r.schema == schema) + return &r; + for (const auto &r : m_catalog.relations) + if (r.name == ref.name) + return &r; + return nullptr; +} + +std::vector SqlCompletionProvider::complete(const SqlContext &ctx) const +{ + switch (ctx.kind) { + case CompletionKind::Relation: + return relationItems(ctx, ctx.qualifier); + case CompletionKind::JoinTarget: { + auto items = joinItems(ctx); + auto rest = relationItems(ctx, QString()); + items.insert(items.end(), std::make_move_iterator(rest.begin()), std::make_move_iterator(rest.end())); + return items; + } + case CompletionKind::Column: + return columnItems(ctx); + case CompletionKind::QualifiedColumn: + return qualifiedColumnItems(ctx); + default: + return {}; + } +} + +std::vector SqlCompletionProvider::relationItems(const SqlContext &ctx, const QString &schema) const +{ + std::vector items; + auto addRelation = [&](const CompletionRelation &r, bool qualified) { + if (!isQueryable(r.kind) || !matchesPrefix(r.name, ctx)) + return; + CompletionItem item; + item.display = r.name; + item.detail = qualified ? kindDescription(r.kind) : r.schema + QStringLiteral(" ") + kindDescription(r.kind); + item.insertText = quoteIdent(r.name); + item.kind = CompletionKind::Relation; + items.push_back(std::move(item)); + }; + + if (!schema.isEmpty()) { + for (const auto &r : m_catalog.relations) + if (r.schema == schema) + addRelation(r, true); + } + else { + for (const auto &s : m_catalog.schemas) { + if (!matchesPrefix(s, ctx)) + continue; + CompletionItem item; + item.display = s; + item.detail = QStringLiteral("schema"); + item.insertText = quoteIdent(s); + item.kind = CompletionKind::Schema; + items.push_back(std::move(item)); + } + for (const auto &sp : m_catalog.searchPath) + for (const auto &r : m_catalog.relations) + if (r.schema == sp) + addRelation(r, false); + } + auto byName = [](const CompletionItem &a, const CompletionItem &b) { + if (a.kind != b.kind) + return a.kind == CompletionKind::Schema; // schemas first + return QString::compare(a.display, b.display, Qt::CaseInsensitive) < 0; + }; + std::stable_sort(items.begin(), items.end(), byName); + return items; +} + +std::vector SqlCompletionProvider::columnItems(const SqlContext &ctx) const +{ + struct Source { + const RelationRef *ref; + const CompletionRelation *rel; + }; + std::vector sources; + for (const auto &ref : ctx.relations) { + const CompletionRelation *rel = resolve(ref); + if (rel) + sources.push_back({ &ref, rel }); + } + + std::map counts; // number of in scope relations having this column + for (const auto &s : sources) + for (const auto &c : s.rel->columns) + ++counts[c]; + + std::vector items; + for (const auto &s : sources) { + const QString refName = referenceName(*s.ref); + for (const auto &c : s.rel->columns) { + if (!matchesPrefix(c, ctx)) + continue; + CompletionItem item; + item.display = c; + item.detail = refName; + item.ambiguous = counts[c] > 1; + item.insertText = item.ambiguous ? quoteIdent(refName) + QLatin1Char('.') + quoteIdent(c) : quoteIdent(c); + item.kind = CompletionKind::Column; + items.push_back(std::move(item)); + } + } + + addFunctionItems(ctx, items); + + // names usable as qualifier + std::vector seen; + for (const auto &ref : ctx.relations) { + const QString refName = referenceName(ref); + if (!matchesPrefix(refName, ctx) || std::find(seen.begin(), seen.end(), refName) != seen.end()) + continue; + seen.push_back(refName); + CompletionItem item; + item.display = refName; + item.detail = ref.alias.isEmpty() ? QStringLiteral("relation") : QStringLiteral("alias of ") + ref.name; + item.insertText = quoteIdent(refName); + item.kind = CompletionKind::Relation; + items.push_back(std::move(item)); + } + return items; +} + +namespace { + +QString initialsOf(const QString &name) +{ + QString result; + bool startOfWord = true; + for (QChar c : name) { + if (c.isLetterOrNumber()) { + if (startOfWord) + result += c.toLower(); + startOfWord = false; + } + else + startOfWord = true; + } + return result.isEmpty() ? QStringLiteral("t") : result; +} + +QString uniqueAlias(const QString &tableName, const std::set &taken) +{ + const QString base = initialsOf(tableName); + QString candidate = base; + int n = 2; + while (taken.count(candidate.toLower()) || identNeedsQuotes(candidate)) + candidate = base + QString::number(n++); + return candidate; +} + +} // namespace + +std::vector SqlCompletionProvider::joinItems(const SqlContext &ctx) const +{ + std::vector items; + std::set taken; + for (const auto &ref : ctx.relations) { + taken.insert(ref.name.toLower()); + if (!ref.alias.isEmpty()) + taken.insert(ref.alias.toLower()); + } + + auto isSearchPath = [&](const QString &schema) { + return std::find(m_catalog.searchPath.begin(), m_catalog.searchPath.end(), schema) != m_catalog.searchPath.end(); + }; + + for (const auto &ref : ctx.relations) { + const CompletionRelation *rel = resolve(ref); + if (!rel) + continue; + const QString refName = quoteIdent(referenceName(ref)); + + for (const auto &fk : m_catalog.foreignKeys) { + // both directions, a self referencing key gives both + for (int dir = 0; dir < 2; ++dir) { + const bool fromChild = dir == 0; + const QString &fromSchema = fromChild ? fk.childSchema : fk.parentSchema; + const QString &fromTable = fromChild ? fk.childTable : fk.parentTable; + if (fromTable != rel->name || fromSchema != rel->schema) + continue; + const QString &toSchema = fromChild ? fk.parentSchema : fk.childSchema; + const QString &toTable = fromChild ? fk.parentTable : fk.childTable; + const auto &fromCols = fromChild ? fk.childColumns : fk.parentColumns; + const auto &toCols = fromChild ? fk.parentColumns : fk.childColumns; + if (!matchesPrefix(toTable, ctx)) + continue; + + const QString alias = uniqueAlias(toTable, taken); + QString on; + for (size_t i = 0; i < fromCols.size(); ++i) { + if (i > 0) + on += QStringLiteral(" AND "); + on += refName + QLatin1Char('.') + quoteIdent(fromCols[i]) + QStringLiteral(" = ") + + alias + QLatin1Char('.') + quoteIdent(toCols[i]); + } + QString target = quoteIdent(toTable); + if (!isSearchPath(toSchema)) + target = quoteIdent(toSchema) + QLatin1Char('.') + target; + + CompletionItem item; + item.display = toTable + QLatin1Char(' ') + alias; + item.detail = QStringLiteral("ON ") + on; + item.insertText = target + QLatin1Char(' ') + alias + QStringLiteral(" ON ") + on; + item.kind = CompletionKind::JoinTarget; + const bool duplicate = std::any_of(items.begin(), items.end(), [&](const CompletionItem &i) { + return i.insertText == item.insertText; + }); + if (!duplicate) + items.push_back(std::move(item)); + } + } + } + return items; +} + +void SqlCompletionProvider::addFunctionItems(const SqlContext &ctx, std::vector &items) const +{ + // the list of functions is long, wait until something has been typed + if (ctx.prefix.isEmpty() || ctx.clause == SqlClause::InsertInto) + return; + std::set seen; + for (const auto &f : m_catalog.functions) { + if (std::find(m_catalog.searchPath.begin(), m_catalog.searchPath.end(), f.schema) == m_catalog.searchPath.end()) + continue; + if (!matchesPrefix(f.name, ctx) || !seen.insert(f.name).second) + continue; + CompletionItem item; + item.display = f.name; + item.detail = f.kind == 'a' ? QStringLiteral("aggregate") : f.kind == 'w' ? QStringLiteral("window function") : QStringLiteral("function"); + item.insertText = quoteIdent(f.name) + QLatin1Char('('); + item.kind = CompletionKind::Function; + items.push_back(std::move(item)); + } +} + +std::vector SqlCompletionProvider::qualifiedColumnItems(const SqlContext &ctx) const +{ + std::vector items; + bool found = false; + for (const auto &ref : ctx.relations) { + bool match = false; + if (!ref.alias.isEmpty()) + match = ref.alias == ctx.qualifier; + else + match = ref.name == ctx.qualifier && (ctx.qualifierSchema.isEmpty() || ref.schema == ctx.qualifierSchema); + if (!match) + continue; + found = true; + const CompletionRelation *rel = resolve(ref); + if (!rel) + continue; + for (const auto &c : rel->columns) { + if (!matchesPrefix(c, ctx)) + continue; + CompletionItem item; + item.display = c; + item.detail = rel->name; + item.insertText = quoteIdent(c); + item.kind = CompletionKind::Column; + items.push_back(std::move(item)); + } + } + if (!found) { + // maybe the qualifier is a schema, or a table that is not mentioned in the FROM clause + if (!ctx.qualifierSchema.isEmpty()) { + for (const auto &r : m_catalog.relations) { + if (r.schema != ctx.qualifierSchema || r.name != ctx.qualifier) + continue; + for (const auto &c : r.columns) { + if (!matchesPrefix(c, ctx)) + continue; + CompletionItem item; + item.display = c; + item.detail = r.name; + item.insertText = quoteIdent(c); + item.kind = CompletionKind::Column; + items.push_back(std::move(item)); + } + } + } + else + return relationItems(ctx, ctx.qualifier); + } + return items; +} diff --git a/pglablib/SqlCompletionProvider.h b/pglablib/SqlCompletionProvider.h new file mode 100644 index 0000000..8a6ea18 --- /dev/null +++ b/pglablib/SqlCompletionProvider.h @@ -0,0 +1,69 @@ +#ifndef SQLCOMPLETIONPROVIDER_H +#define SQLCOMPLETIONPROVIDER_H + +#include "SqlContext.h" +#include +#include +#include + +class PgDatabaseCatalog; + +struct CompletionItem { + QString display; // text shown in the list + QString detail; // dimmed extra information, for example the relation a column belongs to + QString insertText; // replaces the typed prefix when accepted + CompletionKind kind = CompletionKind::None; + bool ambiguous = false; // name occurs in more than one relation in scope +}; + +/// Plain snapshot of the parts of the catalog needed for completion, so the provider does not need a database. +struct CompletionRelation { + QString schema; + QString name; + char kind = 'r'; // pg_class.relkind + std::vector columns; // in column order, without dropped and system columns +}; + +struct CompletionFunction { + QString schema; + QString name; + char kind = 'f'; // pg_proc.prokind: f function, a aggregate, w window +}; + +struct CompletionForeignKey { + QString childSchema, childTable; // table that has the constraint + QString parentSchema, parentTable; // referenced table + std::vector childColumns; + std::vector parentColumns; // same length as childColumns +}; + +struct CompletionCatalog { + std::vector schemas; + std::vector relations; + std::vector functions; + std::vector foreignKeys; + /// Schemas that are searched when a relation is not schema qualified, in order + std::vector searchPath = { QStringLiteral("public"), QStringLiteral("pg_catalog") }; +}; + +CompletionCatalog buildCompletionCatalog(const PgDatabaseCatalog &catalog); + +class SqlCompletionProvider { +public: + SqlCompletionProvider() = default; + explicit SqlCompletionProvider(CompletionCatalog catalog); + + std::vector complete(const SqlContext &ctx) const; + +private: + CompletionCatalog m_catalog; + + const CompletionRelation* resolve(const RelationRef &ref) const; + std::vector relationItems(const SqlContext &ctx, const QString &schema) const; + std::vector columnItems(const SqlContext &ctx) const; + std::vector qualifiedColumnItems(const SqlContext &ctx) const; + std::vector joinItems(const SqlContext &ctx) const; + void addFunctionItems(const SqlContext &ctx, std::vector &items) const; +}; + +#endif // SQLCOMPLETIONPROVIDER_H diff --git a/releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml b/releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml new file mode 100644 index 0000000..dd66446 --- /dev/null +++ b/releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + The query editor now offers context aware completion of schema, table and column names, while typing or with Ctrl+Space. Names that need quoting are inserted quoted, and column names that exist in more than one table of the query are shown with their table or alias and inserted qualified. + - | + Completion also works in UPDATE, INSERT, DELETE and RETURNING, offers functions, and after JOIN proposes complete "table alias ON ..." clauses deduced from foreign keys, in both directions and for multi-column keys. diff --git a/tests/pglabtests/CMakeLists.txt b/tests/pglabtests/CMakeLists.txt index 01fe58a..4410a36 100644 --- a/tests/pglabtests/CMakeLists.txt +++ b/tests/pglabtests/CMakeLists.txt @@ -9,6 +9,8 @@ add_executable(pglabtests tst_escapeConnectionStringValue.cpp tst_expected.cpp tst_SqlLexer.cpp + tst_SqlContext.cpp + tst_SqlCompletionProvider.cpp tst_scopeguard.cpp tst_CsvWriter.cpp tst_PasswordManager.cpp diff --git a/tests/pglabtests/tst_SqlCompletionProvider.cpp b/tests/pglabtests/tst_SqlCompletionProvider.cpp new file mode 100644 index 0000000..8e7cfb7 --- /dev/null +++ b/tests/pglabtests/tst_SqlCompletionProvider.cpp @@ -0,0 +1,237 @@ +#include +#include "SqlCompletionProvider.h" +#include "PrintTo_Qt.h" + +using namespace testing; + +namespace { + +CompletionCatalog testCatalog() +{ + CompletionCatalog c; + c.schemas = { "public", "sales" }; + c.relations = { + { "public", "customers", 'r', { "id", "name", "select" } }, + { "public", "orders", 'r', { "id", "customer_id", "Total" } }, + { "public", "order_view", 'v', { "id" } }, + { "sales", "invoices", 'r', { "inv_id" } }, + { "public", "My Table", 'r', { "a" } }, + }; + return c; +} + +std::vector complete(QString text) +{ + int pos = text.indexOf('|'); + text.remove(pos, 1); + SqlCompletionProvider p(testCatalog()); + return p.complete(analyzeSqlContext(text, pos)); +} + +const CompletionItem* find(const std::vector &items, const QString &display, const QString &detail = QString()) +{ + for (auto &i : items) + if (i.display == display && (detail.isEmpty() || i.detail == detail)) + return &i; + return nullptr; +} + +} // namespace + +TEST(SqlCompletionProvider, relationsAndSchemasAfterFrom) +{ + auto items = complete("SELECT * FROM |"); + ASSERT_TRUE(find(items, "sales")); + EXPECT_EQ(find(items, "sales")->kind, CompletionKind::Schema); + ASSERT_TRUE(find(items, "customers")); + EXPECT_EQ(find(items, "customers")->insertText, "customers"); + EXPECT_FALSE(find(items, "invoices")); +} + +TEST(SqlCompletionProvider, relationNeedingQuotesIsQuoted) +{ + auto items = complete("SELECT * FROM my|"); + // prefix match is case-insensitive + ASSERT_TRUE(find(items, "My Table")); + EXPECT_EQ(find(items, "My Table")->insertText, "\"My Table\""); +} + +TEST(SqlCompletionProvider, relationsOfSchema) +{ + auto items = complete("SELECT * FROM sales.|"); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].display, "invoices"); +} + +TEST(SqlCompletionProvider, prefixFilters) +{ + auto items = complete("SELECT * FROM ord|"); + EXPECT_TRUE(find(items, "orders")); + EXPECT_TRUE(find(items, "order_view")); + EXPECT_FALSE(find(items, "customers")); +} + +TEST(SqlCompletionProvider, reservedWordAndMixedCaseColumnsAreQuoted) +{ + auto items = complete("SELECT | FROM customers, orders o"); + ASSERT_TRUE(find(items, "select")); + EXPECT_EQ(find(items, "select")->insertText, "\"select\""); + ASSERT_TRUE(find(items, "Total")); + EXPECT_EQ(find(items, "Total")->insertText, "\"Total\""); + ASSERT_TRUE(find(items, "name")); + EXPECT_EQ(find(items, "name")->insertText, "name"); +} + +TEST(SqlCompletionProvider, ambiguousColumnsAreQualified) +{ + auto items = complete("SELECT | FROM customers, orders o"); + auto c = find(items, "id", "customers"); + auto o = find(items, "id", "o"); + ASSERT_TRUE(c); + ASSERT_TRUE(o); + EXPECT_TRUE(c->ambiguous); + EXPECT_TRUE(o->ambiguous); + EXPECT_EQ(c->insertText, "customers.id"); + EXPECT_EQ(o->insertText, "o.id"); + EXPECT_FALSE(find(items, "customer_id")->ambiguous); +} + +TEST(SqlCompletionProvider, qualifiedByAlias) +{ + auto items = complete("SELECT o.| FROM customers c, orders o"); + ASSERT_EQ(items.size(), 3u); + EXPECT_EQ(items[1].display, "customer_id"); + EXPECT_EQ(items[0].insertText, "id"); +} + +TEST(SqlCompletionProvider, qualifiedByTableName) +{ + auto items = complete("SELECT customers.na| FROM customers"); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].display, "name"); +} + +TEST(SqlCompletionProvider, quotedPrefixIsCaseSensitive) +{ + auto items = complete("SELECT \"To| FROM orders"); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].insertText, "\"Total\""); +} + +TEST(SqlCompletionProvider, unknownRelationGivesNoColumns) +{ + auto items = complete("SELECT | FROM nonexistent"); + EXPECT_TRUE(items.empty() || !find(items, "id")); +} + +TEST(SqlCompletionProvider, qualifierWithoutMatchIsSchema) +{ + auto items = complete("SELECT sales.| FROM customers"); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].display, "invoices"); +} + +TEST(SqlCompletionProvider, insertColumnListOnlyTargetColumns) +{ + auto items = complete("INSERT INTO orders (|"); + ASSERT_EQ(items.size(), 4u); // 3 columns + the relation name + EXPECT_TRUE(find(items, "customer_id")); +} + +TEST(SqlCompletionProvider, updateSetColumns) +{ + auto items = complete("UPDATE customers SET na| = 1"); + ASSERT_TRUE(find(items, "name")); +} + +TEST(SqlCompletionProvider, functionsOnlyWithPrefix) +{ + CompletionCatalog c = testCatalog(); + c.functions = { { "pg_catalog", "count", 'a' }, { "pg_catalog", "coalesce", 'f' }, { "other", "cosmic", 'f' }, { "pg_catalog", "count", 'f' } }; + SqlCompletionProvider p(c); + auto run = [&](QString text) { + int pos = text.indexOf('|'); + text.remove(pos, 1); + return p.complete(analyzeSqlContext(text, pos)); + }; + auto items = run("SELECT co| FROM customers"); + ASSERT_TRUE(find(items, "count")); + EXPECT_EQ(find(items, "count")->insertText, "count("); + EXPECT_EQ(find(items, "count")->kind, CompletionKind::Function); + EXPECT_TRUE(find(items, "coalesce")); + EXPECT_FALSE(find(items, "cosmic")); + EXPECT_FALSE(find(run("SELECT | FROM customers"), "count")); + EXPECT_FALSE(find(run("INSERT INTO customers (co|"), "count")); +} + +namespace { + +std::vector completeJoin(QString text) +{ + CompletionCatalog c = testCatalog(); + c.relations.push_back({ "public", "order_lines", 'r', { "order_id", "line_no", "sku" } }); + c.relations.push_back({ "public", "employees", 'r', { "id", "boss_id" } }); + c.foreignKeys = { + { "public", "orders", "public", "customers", { "customer_id" }, { "id" } }, + { "public", "order_lines", "public", "orders", { "order_id", "line_no" }, { "id", "line_no" } }, + { "sales", "invoices", "public", "customers", { "inv_id" }, { "id" } }, + { "public", "employees", "public", "employees", { "boss_id" }, { "id" } }, + }; + int pos = text.indexOf('|'); + text.remove(pos, 1); + SqlCompletionProvider p(c); + return p.complete(analyzeSqlContext(text, pos)); +} + +} // namespace + +TEST(SqlCompletionProvider, joinFromChildToParent) +{ + auto items = completeJoin("SELECT * FROM orders o JOIN |"); + auto *item = find(items, "customers c"); + ASSERT_TRUE(item); + EXPECT_EQ(item->kind, CompletionKind::JoinTarget); + EXPECT_EQ(item->insertText, "customers c ON o.customer_id = c.id"); + // plain relations are still offered + bool plain = false; + for (auto &i : items) + plain |= i.kind == CompletionKind::Relation && i.display == "customers"; + EXPECT_TRUE(plain); +} + +TEST(SqlCompletionProvider, joinFromParentToChildMultiColumn) +{ + auto items = completeJoin("SELECT * FROM orders JOIN order_l|"); + auto *item = find(items, "order_lines ol"); + ASSERT_TRUE(item); + EXPECT_EQ(item->insertText, "order_lines ol ON orders.id = ol.order_id AND orders.line_no = ol.line_no"); +} + +TEST(SqlCompletionProvider, joinAliasAvoidsClash) +{ + auto items = completeJoin("SELECT * FROM orders c JOIN |"); + auto *item = find(items, "customers c2"); + ASSERT_TRUE(item); + EXPECT_EQ(item->insertText, "customers c2 ON c.customer_id = c2.id"); +} + +TEST(SqlCompletionProvider, joinOtherSchemaQualified) +{ + auto items = completeJoin("SELECT * FROM customers JOIN |"); + auto *item = find(items, "invoices i"); + ASSERT_TRUE(item); + EXPECT_EQ(item->insertText, "sales.invoices i ON customers.id = i.inv_id"); +} + +TEST(SqlCompletionProvider, joinSelfReference) +{ + auto items = completeJoin("SELECT * FROM employees e JOIN |"); + auto *item = find(items, "employees e2"); + ASSERT_TRUE(item); + // both directions give a different condition + int count = 0; + for (auto &i : items) + if (i.kind == CompletionKind::JoinTarget && i.display == "employees e2") + ++count; + EXPECT_EQ(count, 2); +} diff --git a/tests/pglabtests/tst_SqlContext.cpp b/tests/pglabtests/tst_SqlContext.cpp new file mode 100644 index 0000000..dab58bc --- /dev/null +++ b/tests/pglabtests/tst_SqlContext.cpp @@ -0,0 +1,271 @@ +#include +#include "SqlContext.h" +#include "PrintTo_Qt.h" + +using namespace testing; + +namespace { + +// '|' in the text marks the cursor +SqlContext analyze(QString text) +{ + int pos = text.indexOf('|'); + text.remove(pos, 1); + return analyzeSqlContext(text, pos); +} + +QStringList names(const SqlContext &ctx) +{ + QStringList l; + for (auto &r : ctx.relations) + l << (r.schema.isEmpty() ? r.name : r.schema + "." + r.name) + (r.alias.isEmpty() ? "" : "/" + r.alias); + return l; +} + +} // namespace + +TEST(SqlContext, emptyDocument) +{ + auto ctx = analyzeSqlContext("", 0); + EXPECT_EQ(ctx.kind, CompletionKind::None); +} + +TEST(SqlContext, selectListColumnWithFromAfterCursor) +{ + auto ctx = analyze("SELECT | FROM t"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.clause, SqlClause::Select); + EXPECT_EQ(names(ctx), QStringList({ "t" })); + EXPECT_TRUE(ctx.prefix.isEmpty()); +} + +TEST(SqlContext, prefixIsReplaceRange) +{ + auto ctx = analyze("SELECT na| FROM t"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.prefix, "na"); + EXPECT_EQ(ctx.prefixStart, 7); + EXPECT_EQ(ctx.prefixLen, 2); + EXPECT_EQ(names(ctx), QStringList({ "t" })); +} + +TEST(SqlContext, relationAfterFrom) +{ + auto ctx = analyze("SELECT * FROM us|"); + EXPECT_EQ(ctx.kind, CompletionKind::Relation); + EXPECT_EQ(ctx.prefix, "us"); + EXPECT_TRUE(ctx.relations.empty()); +} + +TEST(SqlContext, relationAfterSchemaDot) +{ + auto ctx = analyze("SELECT * FROM sch.|"); + EXPECT_EQ(ctx.kind, CompletionKind::Relation); + EXPECT_EQ(ctx.qualifier, "sch"); + EXPECT_TRUE(ctx.relations.empty()); +} + +TEST(SqlContext, noRelationAfterAlias) +{ + auto ctx = analyze("SELECT * FROM t a|"); + EXPECT_EQ(ctx.kind, CompletionKind::None); +} + +TEST(SqlContext, relationAfterComma) +{ + auto ctx = analyze("SELECT * FROM t, |"); + EXPECT_EQ(ctx.kind, CompletionKind::Relation); + EXPECT_EQ(names(ctx), QStringList({ "t" })); +} + +TEST(SqlContext, aliasQualifier) +{ + auto ctx = analyze("SELECT a.| FROM public.tbl AS a JOIN other o ON o.id = a.id"); + EXPECT_EQ(ctx.kind, CompletionKind::QualifiedColumn); + EXPECT_EQ(ctx.qualifier, "a"); + EXPECT_EQ(names(ctx), QStringList({ "public.tbl/a", "other/o" })); +} + +TEST(SqlContext, schemaQualifiedTableQualifier) +{ + auto ctx = analyze("SELECT s.t.c| FROM s.t"); + EXPECT_EQ(ctx.kind, CompletionKind::QualifiedColumn); + EXPECT_EQ(ctx.qualifierSchema, "s"); + EXPECT_EQ(ctx.qualifier, "t"); + EXPECT_EQ(ctx.prefix, "c"); +} + +TEST(SqlContext, onClause) +{ + auto ctx = analyze("SELECT * FROM a x JOIN b y ON x.id = |"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.clause, SqlClause::On); + EXPECT_EQ(names(ctx), QStringList({ "a/x", "b/y" })); +} + +TEST(SqlContext, relationAfterJoin) +{ + auto ctx = analyze("SELECT * FROM a x LEFT JOIN |"); + EXPECT_EQ(ctx.kind, CompletionKind::JoinTarget); + EXPECT_EQ(ctx.clause, SqlClause::Join); + EXPECT_EQ(names(ctx), QStringList({ "a/x" })); + + EXPECT_EQ(analyze("SELECT * FROM a x JOIN b|").kind, CompletionKind::JoinTarget); + EXPECT_EQ(analyze("SELECT * FROM a x JOIN sch.|").kind, CompletionKind::Relation); + EXPECT_EQ(analyze("SELECT * FROM a x JOIN b y |").kind, CompletionKind::None); +} + +TEST(SqlContext, whereGroupOrderHaving) +{ + EXPECT_EQ(analyze("SELECT * FROM t WHERE |").clause, SqlClause::Where); + EXPECT_EQ(analyze("SELECT * FROM t GROUP BY |").clause, SqlClause::GroupBy); + EXPECT_EQ(analyze("SELECT * FROM t ORDER BY |").clause, SqlClause::OrderBy); + EXPECT_EQ(analyze("SELECT * FROM t GROUP BY a HAVING |").clause, SqlClause::Having); +} + +TEST(SqlContext, quotedPrefix) +{ + auto ctx = analyze("SELECT \"My| FROM t"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_TRUE(ctx.prefixWasQuoted); + EXPECT_EQ(ctx.prefix, "My"); + EXPECT_EQ(ctx.prefixStart, 7); + EXPECT_EQ(ctx.prefixLen, 3); + EXPECT_EQ(names(ctx), QStringList({ "t" })); +} + +TEST(SqlContext, quotedRelationNamesKeepCase) +{ + auto ctx = analyze("SELECT | FROM \"My Tab\" AS \"X\", Other O"); + EXPECT_EQ(names(ctx), QStringList({ "My Tab/X", "other/o" })); + EXPECT_TRUE(ctx.relations[0].quoted); +} + +TEST(SqlContext, multipleStatements) +{ + auto ctx = analyze("SELECT * FROM a; SELECT | FROM b; SELECT 1 FROM c"); + EXPECT_EQ(names(ctx), QStringList({ "b" })); +} + +TEST(SqlContext, subQueryHasOwnScopeAndSeesParent) +{ + auto ctx = analyze("SELECT * FROM a WHERE a.id IN (SELECT | FROM b)"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(names(ctx), QStringList({ "b", "a" })); +} + +TEST(SqlContext, derivedTableDoesNotSeeParent) +{ + auto ctx = analyze("SELECT * FROM a, (SELECT | FROM b) d"); + EXPECT_EQ(names(ctx), QStringList({ "b" })); + auto ctx2 = analyze("SELECT | FROM a, (SELECT 1 FROM b) d"); + EXPECT_EQ(names(ctx2), QStringList({ "a", "d/d" })); + EXPECT_TRUE(ctx2.relations[1].derived); +} + +TEST(SqlContext, functionCallParenInheritsClause) +{ + auto ctx = analyze("SELECT count(|) FROM t"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(names(ctx), QStringList({ "t" })); +} + +TEST(SqlContext, cte) +{ + auto ctx = analyze("WITH x (a) AS (SELECT 1), y AS MATERIALIZED (SELECT 2) SELECT | FROM x JOIN t ON true"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(names(ctx), QStringList({ "x", "t", "x", "y" })); + EXPECT_TRUE(ctx.relations[2].derived); +} + +TEST(SqlContext, unionSegments) +{ + auto ctx = analyze("SELECT * FROM a UNION SELECT | FROM b"); + EXPECT_EQ(names(ctx), QStringList({ "b" })); +} + +TEST(SqlContext, noCompletionInCommentOrString) +{ + EXPECT_EQ(analyze("SELECT * FROM t -- co|").kind, CompletionKind::None); + EXPECT_EQ(analyze("SELECT * FROM t /* co| */").kind, CompletionKind::None); + EXPECT_EQ(analyze("SELECT 'ab|c' FROM t").kind, CompletionKind::None); +} + +TEST(SqlContext, incompleteSql) +{ + auto ctx = analyze("SELECT a.id, b.| FROM a, b WHERE"); + EXPECT_EQ(ctx.kind, CompletionKind::QualifiedColumn); + EXPECT_EQ(ctx.qualifier, "b"); + EXPECT_EQ(names(ctx), QStringList({ "a", "b" })); +} + +TEST(SqlContext, updateSetAndWhere) +{ + auto ctx = analyze("UPDATE sch.t AS x SET |"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.clause, SqlClause::Set); + EXPECT_EQ(names(ctx), QStringList({ "sch.t/x" })); + + auto ctx2 = analyze("UPDATE t SET a = 1, b = 2 WHERE |"); + EXPECT_EQ(ctx2.clause, SqlClause::Where); + EXPECT_EQ(names(ctx2), QStringList({ "t" })); +} + +TEST(SqlContext, updateRelation) +{ + auto ctx = analyze("UPDATE |"); + EXPECT_EQ(ctx.kind, CompletionKind::Relation); +} + +TEST(SqlContext, updateFrom) +{ + auto ctx = analyze("UPDATE t SET a = u.| FROM u WHERE t.id = u.id"); + EXPECT_EQ(ctx.kind, CompletionKind::QualifiedColumn); + EXPECT_EQ(names(ctx), QStringList({ "t", "u" })); +} + +TEST(SqlContext, deleteFrom) +{ + auto ctx = analyze("DELETE FROM |"); + EXPECT_EQ(ctx.kind, CompletionKind::Relation); + auto ctx2 = analyze("DELETE FROM t WHERE |"); + EXPECT_EQ(ctx2.kind, CompletionKind::Column); + EXPECT_EQ(names(ctx2), QStringList({ "t" })); +} + +TEST(SqlContext, insertIntoRelation) +{ + EXPECT_EQ(analyze("INSERT INTO |").kind, CompletionKind::Relation); + EXPECT_EQ(analyze("INSERT INTO sch.t|").kind, CompletionKind::Relation); +} + +TEST(SqlContext, insertColumnList) +{ + auto ctx = analyze("INSERT INTO s.t (|"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.clause, SqlClause::InsertInto); + EXPECT_EQ(names(ctx), QStringList({ "s.t" })); + + auto ctx2 = analyze("INSERT INTO t (a, b|) VALUES (1, 2)"); + EXPECT_EQ(ctx2.kind, CompletionKind::Column); + EXPECT_EQ(ctx2.prefix, "b"); + EXPECT_EQ(names(ctx2), QStringList({ "t" })); +} + +TEST(SqlContext, insertValuesAndSelect) +{ + auto ctx = analyze("INSERT INTO t (a) VALUES (|)"); + EXPECT_EQ(ctx.clause, SqlClause::Values); + auto ctx2 = analyze("INSERT INTO t (a) SELECT | FROM u"); + EXPECT_EQ(ctx2.kind, CompletionKind::Column); + EXPECT_EQ(names(ctx2), QStringList({ "u" })); +} + +TEST(SqlContext, returning) +{ + auto ctx = analyze("INSERT INTO t (a) VALUES (1) RETURNING |"); + EXPECT_EQ(ctx.kind, CompletionKind::Column); + EXPECT_EQ(ctx.clause, SqlClause::Returning); + EXPECT_EQ(names(ctx), QStringList({ "t" })); + EXPECT_EQ(analyze("DELETE FROM t RETURNING |").clause, SqlClause::Returning); +}