The editor now has context aware completions
This commit is contained in:
parent
1a1bbd0838
commit
960967c8a6
18 changed files with 1984 additions and 0 deletions
|
|
@ -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
538
core/SqlContext.cpp
Normal 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 "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<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
70
core/SqlContext.h
Normal 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
|
||||||
|
|
@ -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
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
112
pglab/codeeditor/CompletionPopup.cpp
Normal file
112
pglab/codeeditor/CompletionPopup.cpp
Normal 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();
|
||||||
|
}
|
||||||
40
pglab/codeeditor/CompletionPopup.h
Normal file
40
pglab/codeeditor/CompletionPopup.h
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
430
pglablib/SqlCompletionProvider.cpp
Normal file
430
pglablib/SqlCompletionProvider.cpp
Normal 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;
|
||||||
|
}
|
||||||
69
pglablib/SqlCompletionProvider.h
Normal file
69
pglablib/SqlCompletionProvider.h
Normal 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
|
||||||
6
releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml
Normal file
6
releasenotes/notes/sql-completion-8d3f1a6b2c4e7f90.yaml
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
237
tests/pglabtests/tst_SqlCompletionProvider.cpp
Normal file
237
tests/pglabtests/tst_SqlCompletionProvider.cpp
Normal 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);
|
||||||
|
}
|
||||||
271
tests/pglabtests/tst_SqlContext.cpp
Normal file
271
tests/pglabtests/tst_SqlContext.cpp
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue