The editor now has context aware completions

This commit is contained in:
eelke 2026-10-22 17:52:37 +02:00
parent 1a1bbd0838
commit 960967c8a6
18 changed files with 1984 additions and 0 deletions

View file

@ -1,11 +1,13 @@
add_library(core STATIC add_library(core STATIC
my_boost_assert_handler.cpp my_boost_assert_handler.cpp
SqlLexer.cpp SqlLexer.cpp
SqlContext.cpp
CsvWriter.cpp CsvWriter.cpp
BackupFormatModel.cpp BackupFormatModel.cpp
ExplainTreeModelItem.cpp ExplainTreeModelItem.cpp
jsoncpp.cpp jsoncpp.cpp
SqlLexer.h SqlLexer.h
SqlContext.h
ScopeGuard.h ScopeGuard.h
CsvWriter.h CsvWriter.h
BackupFormatModel.h BackupFormatModel.h

538
core/SqlContext.cpp Normal file
View file

@ -0,0 +1,538 @@
#include "SqlContext.h"
#include "SqlLexer.h"
#include <QSet>
#include <algorithm>
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<QString> &reservedWords()
{
static const QSet<QString> 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 &quoted)
{
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<Tok> tokenize(const QString &text)
{
std::vector<Tok> 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<Tok>;
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<int> directTokens(const Level &lv) const
{
std::vector<int> 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<int> 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<int> 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<int>(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<RelationRef> &out) const
{
const int n = static_cast<int>(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<const Tok *> 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<RelationRef> &out) const
{
const int n = static_cast<int>(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<RelationRef> 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<RelationRef> &out) const
{
Level top{ 0, static_cast<int>(S.size()), -1 };
std::vector<int> 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<int>(text.size()));
ctx.prefixStart = cursorPos;
// An unterminated quoted identifier before the cursor would swallow the remaining text
QString work = text;
{
std::vector<Tok> head = tokenize(text.left(cursorPos));
if (!head.empty() && head.back().end == cursorPos && isUnterminatedQuote(head.back().text))
work.insert(cursorPos, QLatin1Char('"'));
}
std::vector<Tok> 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<int>(T.size());
for (int i = 0; i < static_cast<int>(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<int>(T.size());
for (int i = removedEnd; i < static_cast<int>(T.size()); ++i)
if (T[i].isSelf(';')) {
se = i;
break;
}
std::vector<Tok> 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<int> 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<int>(S.size());
if (open >= 0) {
int depth = 1;
for (int i = open + 1; i < static_cast<int>(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<Analyzer::Level> levels;
for (int i = static_cast<int>(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;
}

70
core/SqlContext.h Normal file
View file

@ -0,0 +1,70 @@
#ifndef SQLCONTEXT_H
#define SQLCONTEXT_H
#include <QString>
#include <vector>
/**
* 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<RelationRef> 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

View file

@ -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 drag and drop a bunch of files onto the window and they will be opened in
seperate query tabs. 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 Explain
======= =======

View file

@ -25,6 +25,7 @@ set(PGLAB_SOURCES
catalog/widgets/DependantsPage.cpp catalog/widgets/DependantsPage.cpp
catalog/widgets/TriggerPage.cpp catalog/widgets/TriggerPage.cpp
codeeditor/CodeEditor.cpp codeeditor/CodeEditor.cpp
codeeditor/CompletionPopup.cpp
codeeditor/EditorGutter.cpp codeeditor/EditorGutter.cpp
codeeditor/GutterPainter.cpp codeeditor/GutterPainter.cpp
crud/CrudModel.cpp crud/CrudModel.cpp

View file

@ -1,5 +1,6 @@
#include "CodeEditor.h" #include "CodeEditor.h"
#include "EditorGutter.h" #include "EditorGutter.h"
#include <QKeyEvent>
#include <QPainter> #include <QPainter>
#include <QTextBlock> #include <QTextBlock>
#include <QDebug> #include <QDebug>
@ -124,9 +125,125 @@ bool CodeEditor::lineHasError(int blockNumber) const
return errorLines.count(blockNumber) > 0; 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<int>(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) void CodeEditor::keyPressEvent(QKeyEvent *e)
{ {
auto k = e->key(); 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) { switch (k) {
case Qt::Key_Tab: case Qt::Key_Tab:
if (indentSelection(true)) if (indentSelection(true))
@ -155,6 +272,20 @@ void CodeEditor::keyPressEvent(QKeyEvent *e)
break; break;
} }
QPlainTextEdit::keyPressEvent(e); 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);
}
}
} }

View file

@ -4,6 +4,7 @@
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <set> #include <set>
#include "util/Colors.h" #include "util/Colors.h"
#include "CompletionPopup.h"
/** This class adds some capabilities to QPlainTextEdit that are useful /** This class adds some capabilities to QPlainTextEdit that are useful
* in code editor scenarios. * in code editor scenarios.
@ -20,9 +21,16 @@ public:
void clearErrorMarkers(); void clearErrorMarkers();
void setFont(const QFont &); void setFont(const QFont &);
void setTabSize(int chars); 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: protected:
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *e) override; void keyPressEvent(QKeyEvent *e) override;
void focusOutEvent(QFocusEvent *e) override;
void mousePressEvent(QMouseEvent *e) override;
signals: signals:
@ -44,6 +52,14 @@ private:
std::set<int> errorLines; std::set<int> errorLines;
EditorCompletionCallback m_completionCallback;
CompletionPopup *m_completionPopup = nullptr;
EditorCompletionResult m_completionResult;
bool updateCompletions(bool explicitRequest);
void hideCompletions();
void acceptCompletion(int row);
// Settings // Settings
int m_tabSize = 0; // tabSize in characters int m_tabSize = 0; // tabSize in characters
bool m_useTab = false; bool m_useTab = false;

View file

@ -0,0 +1,112 @@
#include "CompletionPopup.h"
#include <QApplication>
#include <QPainter>
#include <QScreen>
#include <QStyledItemDelegate>
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<EditorCompletionItem> &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<int>(std::min<size_t>(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();
}

View file

@ -0,0 +1,40 @@
#ifndef COMPLETIONPOPUP_H
#define COMPLETIONPOPUP_H
#include <QListWidget>
#include <functional>
#include <vector>
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<EditorCompletionItem> items;
};
/// Callback that determines the completions for a cursor position in the text
using EditorCompletionCallback = std::function<EditorCompletionResult(const QString &text, int cursorPos)>;
/// 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<EditorCompletionItem> &items);
void moveSelection(int delta);
int currentRow() const { return QListWidget::currentRow(); }
void showAt(const QPoint &globalPos);
signals:
void itemAccepted(int row);
};
#endif // COMPLETIONPOPUP_H

View file

@ -16,6 +16,7 @@
#include "json/json.h" #include "json/json.h"
#include "OpenDatabase.h" #include "OpenDatabase.h"
#include "catalog/PgDatabaseCatalog.h" #include "catalog/PgDatabaseCatalog.h"
#include "SqlCompletionProvider.h"
#include "Pgsql_PgException.h" #include "Pgsql_PgException.h"
#include "QueryParamListController.h" #include "QueryParamListController.h"
#include "util.h" #include "util.h"
@ -54,6 +55,7 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> docu
connect(m_document.get(), &SqlDocument::modifiedChanged, this, &QueryTool::updateTitle); connect(m_document.get(), &SqlDocument::modifiedChanged, this, &QueryTool::updateTitle);
connect(m_document.get(), &SqlDocument::fileNameChanged, this, &QueryTool::updateTitle); connect(m_document.get(), &SqlDocument::fileNameChanged, this, &QueryTool::updateTitle);
ui->queryEdit->setAcceptDrops(false); ui->queryEdit->setAcceptDrops(false);
setupCompletion();
m_queryParamListController = new QueryParamListController(ui->paramTableView, m_context->openDatabase(), this); m_queryParamListController = new QueryParamListController(ui->paramTableView, m_context->openDatabase(), this);
connect(ui->addButton, &QPushButton::clicked, m_queryParamListController, connect(ui->addButton, &QPushButton::clicked, m_queryParamListController,
@ -64,6 +66,35 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> docu
startConnect(); 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<SqlCompletionProvider>(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() QueryTool::~QueryTool()
{ {
// Detach the editor from the shared document, it may be destroyed together with this tool // Detach the editor from the shared document, it may be destroyed together with this tool

View file

@ -25,6 +25,7 @@ class QTableView;
class QTabWidget; class QTabWidget;
class SqlSyntaxHighlighter; class SqlSyntaxHighlighter;
class SqlCompletionProvider;
class ExplainRoot; class ExplainRoot;
class QueryExplainModel; class QueryExplainModel;
class PgTypeContainer; class PgTypeContainer;
@ -80,6 +81,9 @@ private:
std::shared_ptr<PgDatabaseCatalog> m_catalog; std::shared_ptr<PgDatabaseCatalog> m_catalog;
std::shared_ptr<SqlCompletionProvider> m_completionProvider; ///< built on first use, reset when the catalog is refreshed
void setupCompletion();
ASyncDBConnection m_dbConnection; ASyncDBConnection m_dbConnection;
std::unique_ptr<QueryExplainModel> explainModel; std::unique_ptr<QueryExplainModel> explainModel;

View file

@ -31,6 +31,7 @@ add_library(pglablib STATIC
ui/catalog/tables/TableTreeBuilder.cpp ui/catalog/tables/TableTreeBuilder.cpp
util.cpp util.cpp
SqlFormattingUtils.cpp SqlFormattingUtils.cpp
SqlCompletionProvider.cpp
catalog/PgKeywordList.cpp catalog/PgKeywordList.cpp
QueryGenerator.cpp QueryGenerator.cpp
ResultSourceMapping.cpp ResultSourceMapping.cpp
@ -107,6 +108,7 @@ add_library(pglablib STATIC
ui/catalog/tables/TableTreeBuilder.h ui/catalog/tables/TableTreeBuilder.h
util.h util.h
SqlFormattingUtils.h SqlFormattingUtils.h
SqlCompletionProvider.h
catalog/PgCatalogTypes.h catalog/PgCatalogTypes.h
catalog/PgKeywordList.h catalog/PgKeywordList.h
QueryGenerator.h QueryGenerator.h

View file

@ -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 <set>
#include <algorithm>
#include <map>
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<QString> 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<CompletionItem> 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<CompletionItem> SqlCompletionProvider::relationItems(const SqlContext &ctx, const QString &schema) const
{
std::vector<CompletionItem> 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<CompletionItem> SqlCompletionProvider::columnItems(const SqlContext &ctx) const
{
struct Source {
const RelationRef *ref;
const CompletionRelation *rel;
};
std::vector<Source> sources;
for (const auto &ref : ctx.relations) {
const CompletionRelation *rel = resolve(ref);
if (rel)
sources.push_back({ &ref, rel });
}
std::map<QString, int> 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<CompletionItem> 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<QString> 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<QString> &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<CompletionItem> SqlCompletionProvider::joinItems(const SqlContext &ctx) const
{
std::vector<CompletionItem> items;
std::set<QString> 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<CompletionItem> &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<QString> 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<CompletionItem> SqlCompletionProvider::qualifiedColumnItems(const SqlContext &ctx) const
{
std::vector<CompletionItem> 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;
}

View file

@ -0,0 +1,69 @@
#ifndef SQLCOMPLETIONPROVIDER_H
#define SQLCOMPLETIONPROVIDER_H
#include "SqlContext.h"
#include <QString>
#include <memory>
#include <vector>
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<QString> 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<QString> childColumns;
std::vector<QString> parentColumns; // same length as childColumns
};
struct CompletionCatalog {
std::vector<QString> schemas;
std::vector<CompletionRelation> relations;
std::vector<CompletionFunction> functions;
std::vector<CompletionForeignKey> foreignKeys;
/// Schemas that are searched when a relation is not schema qualified, in order
std::vector<QString> searchPath = { QStringLiteral("public"), QStringLiteral("pg_catalog") };
};
CompletionCatalog buildCompletionCatalog(const PgDatabaseCatalog &catalog);
class SqlCompletionProvider {
public:
SqlCompletionProvider() = default;
explicit SqlCompletionProvider(CompletionCatalog catalog);
std::vector<CompletionItem> complete(const SqlContext &ctx) const;
private:
CompletionCatalog m_catalog;
const CompletionRelation* resolve(const RelationRef &ref) const;
std::vector<CompletionItem> relationItems(const SqlContext &ctx, const QString &schema) const;
std::vector<CompletionItem> columnItems(const SqlContext &ctx) const;
std::vector<CompletionItem> qualifiedColumnItems(const SqlContext &ctx) const;
std::vector<CompletionItem> joinItems(const SqlContext &ctx) const;
void addFunctionItems(const SqlContext &ctx, std::vector<CompletionItem> &items) const;
};
#endif // SQLCOMPLETIONPROVIDER_H

View file

@ -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.

View file

@ -9,6 +9,8 @@ add_executable(pglabtests
tst_escapeConnectionStringValue.cpp tst_escapeConnectionStringValue.cpp
tst_expected.cpp tst_expected.cpp
tst_SqlLexer.cpp tst_SqlLexer.cpp
tst_SqlContext.cpp
tst_SqlCompletionProvider.cpp
tst_scopeguard.cpp tst_scopeguard.cpp
tst_CsvWriter.cpp tst_CsvWriter.cpp
tst_PasswordManager.cpp tst_PasswordManager.cpp

View file

@ -0,0 +1,237 @@
#include <gtest/gtest.h>
#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<CompletionItem> 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<CompletionItem> &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<CompletionItem> 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);
}

View file

@ -0,0 +1,271 @@
#include <gtest/gtest.h>
#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);
}