Compare commits

..

No commits in common. "main" and "build-on-windows" have entirely different histories.

68 changed files with 655 additions and 4656 deletions

3
.gitignore vendored
View file

@ -12,5 +12,4 @@ pglabAll.pro.user.4.8-pre1
*.user
/pglabAll.pro.user*
/public/
/Testing/Temporary
.junie/
/Testing/Temporary

View file

@ -28,7 +28,7 @@ Configure with the vcpkg toolchain file. The first configure downloads and build
```
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
cmake --build build --parallel
cmake --build build
```
To skip the test executables, add `-DBUILD_TESTS=OFF` to the configure command.
@ -39,7 +39,7 @@ Tested with Visual Studio 2022 (Professional) and Qt 6.8.2 (MSVC 2022, 64-bit).
```
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%\scripts\buildsystems\vcpkg.cmake -DCMAKE_PREFIX_PATH=C:/Qt/6.8.2/msvc2022_64
cmake --build build --config Release --parallel
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure
```
@ -51,23 +51,6 @@ C:\Qt\6.8.2\msvc2022_64\bin\windeployqt.exe --release build\pglab\Release\pglab.
If another Qt installation (for example one shipped with another program) is on your `PATH`, its `Qt6Core.dll` may be loaded instead, which causes "entry point not found" errors at startup. Deploying the DLLs next to the executables avoids this.
### Parallel builds with Ninja on Windows
Open an x64 Visual Studio 2022 Developer PowerShell. Ninja requires the compiler and Windows SDK environment from that shell; adding `cl.exe` to `PATH` alone can leave the linker unable to find `kernel32.lib`.
With `VCPKG_ROOT` set, run these PowerShell commands (adjust the Qt path for your installation):
```powershell
cmake -S . -B build/ninja-release -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DCMAKE_PREFIX_PATH=C:/Qt/6.8.2/msvc2022_64
cmake --build build/ninja-release --parallel 8
$env:PATH = "C:\Qt\6.8.2\msvc2022_64\bin;$PWD\build\ninja-release\vcpkg_installed\x64-windows\bin;$env:PATH"
ctest --test-dir build/ninja-release --output-on-failure
```
`--parallel 8` allows eight build jobs; adjust the number to suit your machine. Ninja also builds in parallel by default. `CMAKE_BUILD_TYPE` selects Release at configure time, and the app is written to `build/ninja-release/pglab/pglab.exe`.
Use a separate build directory when changing generators or adding a toolchain to an existing configuration. The directory above keeps the Ninja configuration separate from an existing Visual Studio build. The Qt prefix and vcpkg toolchain are both needed: Qt is installed separately, while vcpkg supplies the other dependencies.
## Running the tests
From the build directory, run:

View file

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

View file

@ -63,6 +63,32 @@ public:
}
}
// void swap(Expected& rhs)
// {
// if (m_valid) {
// if (rhs.m_valid) {
// using std::swap;
// swap(m_value, rhs.m_value);
// }
// else {
// auto t = std::move(rhs.m_error);
// new(&rhs.m_value) T(std::move(m_value));
// new(&m_error) std::exception_ptr(t);
// std::swap(m_valid, rhs.m_valid);
// }
// }
// else {
// if (rhs.m_valid) {
// rhs.swap(*this);
// }
// else {
// using std::swap;
// swap(m_error, rhs.m_error);
// std::swap(m_valid, rhs.m_valid);
// }
// }
// }
template <class E>
static Expected<T> fromException(const E& exception)
{
@ -118,7 +144,6 @@ public:
return true;
}
catch (...) {
// The stored exception is not of type E. It is still held in m_error.
}
return false;
}
@ -143,7 +168,7 @@ class Expected<void> {
public:
Expected()
Expected<void>()
: m_valid(true)
{}
@ -161,6 +186,26 @@ public:
~Expected()
{}
// void swap(Expected& rhs)
// {
// if (m_valid) {
// if (!rhs.m_valid) {
// m_error = rhs.m_error;
// std::swap(m_valid, rhs.m_valid);
// }
// }
// else {
// if (rhs.m_valid) {
// rhs.swap(*this);
// }
// else {
// using std::swap;
// swap(m_error, rhs.m_error);
// std::swap(m_valid, rhs.m_valid);
// }
// }
// }
template <class E>
static Expected<void> fromException(const E& exception)
{
@ -208,7 +253,6 @@ public:
return true;
}
catch (...) {
// The stored exception is not of type E. It is still held in m_error.
}
return false;
}

View file

@ -1,538 +0,0 @@
#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;
}

View file

@ -1,70 +0,0 @@
#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,28 +25,6 @@ and opened with :kbd:`Ctrl+O` or :menuselection:`File --> Open query`. You can a
drag and drop a bunch of files onto the window and they will be opened in
seperate query tabs.
Completion
==========
The query editor proposes names while you type, or when you press :kbd:`Ctrl+Space`.
After ``FROM``, ``JOIN`` and ``UPDATE`` schemas and tables are proposed,
and after ``schema.`` the relations of that schema. In other places the columns of the
tables in the query are proposed, also when the ``FROM`` clause comes after the cursor.
Type ``alias.`` or ``table.`` to only see the columns of that relation.
Use :kbd:`Up`/:kbd:`Down` to select, :kbd:`Enter` or :kbd:`Tab` to accept and :kbd:`Esc` to close the list.
Names that need quotes, for instance reserved words or names with capitals, are inserted
quoted. When a column name occurs in more than one table of the query, every
occurrence is listed with its table or alias in bold and accepting it inserts the
name qualified, for example ``o.id``.
After ``JOIN`` the list starts with complete join clauses such as
``customers c ON o.customer_id = c.id``, deduced from the foreign keys of the
tables already in the query. Both directions and multi-column keys are supported
and the generated alias never clashes with a name already in use.
Functions are proposed once you have typed the start of their name.
Explain
=======

View file

@ -2,7 +2,6 @@
#include "BackupFormatModel.h"
#include "PgDumpOutputHighlighter.h"
#include "UserConfiguration.h"
#include <QFileDialog>
#include <QMessageBox>
#include <QProcess>
@ -52,7 +51,10 @@ QPlainTextEdit* createStdOutput(QWidget *parent)
brush3.setStyle(Qt::SolidPattern);
palette.setBrush(QPalette::Disabled, QPalette::Base, brush3);
stdOutput->setPalette(palette);
stdOutput->setFont(UserConfiguration::instance()->codeFont());
QFont font;
font.setFamily(QString::fromUtf8("Source Code Pro"));
font.setPointSize(10);
stdOutput->setFont(font);
stdOutput->setLineWrapMode(QPlainTextEdit::NoWrap);
stdOutput->setReadOnly(true);
@ -437,7 +439,7 @@ void BackupDialog::on_btnStart_clicked()
QSettings user_settings; // Default constructor is for UserScope
QVariant var = user_settings.value("pg_tools_path", {});
QDir dir;
if (var.typeId() == QMetaType::QString) {
if (var.type() == QMetaType::QString) {
dir.setPath(var.toString());
}
if (!dir.exists() || !dir.exists("pg_dump.exe")) {

View file

@ -1,4 +1,5 @@
set(PGLAB_SOURCES
main.cpp
About.cpp
catalog/delegates/IconColumnDelegate.cpp
catalog/models/BaseTableModel.cpp
@ -25,19 +26,14 @@ set(PGLAB_SOURCES
catalog/widgets/DependantsPage.cpp
catalog/widgets/TriggerPage.cpp
codeeditor/CodeEditor.cpp
codeeditor/CompletionPopup.cpp
codeeditor/EditorGutter.cpp
codeeditor/GutterPainter.cpp
crud/CrudModel.cpp
crud/CrudTab.cpp
crud/DataGridWidget.cpp
crud/RecordPanel.cpp
querytool/QueryExplainModel.cpp
querytool/QueryParamListController.cpp
querytool/QueryTool.cpp
querytool/SqlDocument.cpp
querytool/SqlDocumentRegistry.cpp
querytool/UnsavedDocumentsDialog.cpp
serverinspector/DatabasesPage.cpp
serverinspector/RolesPage.cpp
serverinspector/ServerInspector.cpp
@ -72,6 +68,8 @@ set(PGLAB_SOURCES
EditTableWidget.cpp
EditColumnTableModel.cpp
DatabaseWindow.cpp
widgets/SingleRecordModel.cpp
widgets/SingleRecordWidget.cpp
)
set(PGLAB_FORMS
@ -83,18 +81,21 @@ set(PGLAB_FORMS
ProcessStdioWidget.ui
crud/CrudTab.ui
CodeGenerator.ui
widgets/SingleRecordWidget.ui
)
add_library(pglabapp STATIC
add_executable(pglab
${PGLAB_SOURCES}
${PGLAB_FORMS}
resources.qrc
)
if(WIN32)
target_link_libraries(pglabapp PUBLIC ws2_32)
set_target_properties(pglab PROPERTIES WIN32_EXECUTABLE ON)
target_link_libraries(pglab PRIVATE ws2_32)
endif()
target_link_libraries(pglabapp PUBLIC
target_link_libraries(pglab PRIVATE
pglablib
pgsql
core
@ -105,27 +106,10 @@ target_link_libraries(pglabapp PUBLIC
Qt6::Concurrent
)
target_include_directories(pglabapp PUBLIC
target_include_directories(pglab PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/crud
)
set_target_properties(pglabapp PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)
add_executable(pglab
main.cpp
resources.qrc
)
if(WIN32)
set_target_properties(pglab PROPERTIES WIN32_EXECUTABLE ON)
endif()
target_link_libraries(pglab PRIVATE pglabapp)
set_target_properties(pglab PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON

View file

@ -117,9 +117,10 @@ ConnectionConfigurationWidget::ConnectionConfigurationWidget(
SET_OBJECT_NAME(lblSsl);
cmbbxSsl = new QComboBox;
SET_OBJECT_NAME(cmbbxSsl);
cmbbxSsl->setModelColumn(SslModeModel::Name);
cmbbxSsl->setModelColumn(0);
auto ssl_model = new SslModeModel(this);
cmbbxSsl->setModel(ssl_model);
cmbbxSsl->setEditable(true);
lblSsl->setBuddy(cmbbxSsl);
lblCert = new QLabel;
@ -206,13 +207,7 @@ void ConnectionConfigurationWidget::setData(const ConnectionConfig &cfg)
edtPassword->setText("");
cmbDbname->setCurrentText(cfg.dbname());
auto sslMode = cfg.getParameter("sslmode");
if (sslMode == "verify_ca")
sslMode = "verify-ca";
else if (sslMode == "verify_full")
sslMode = "verify-full";
auto sslIndex = cmbbxSsl->findData(sslMode);
cmbbxSsl->setCurrentIndex(sslIndex < 0 ? 0 : sslIndex);
cmbbxSsl->setCurrentText(cfg.getParameter("sslmode"));
edtCert->setText(cfg.sslCert());
edtKey->setText(cfg.sslKey());
edtRootCert->setText(cfg.sslRootCert());
@ -239,7 +234,7 @@ ConnectionConfig ConnectionConfigurationWidget::data() const
cfg.setUser(edtUser->text());
cfg.setPassword(edtPassword->text());
cfg.setDbname(cmbDbname->currentText());
cfg.setParameter("sslmode", cmbbxSsl->currentData().toString());
cfg.setParameter("sslmode", cmbbxSsl->currentText());
cfg.setSslCert(edtCert->text());
cfg.setSslKey(edtKey->text());
cfg.setSslRootCert(edtRootCert->text());

View file

@ -62,7 +62,7 @@ void ConnectionManagerWindow::on_actionQuit_application_triggered()
{
auto res = QMessageBox::question(this, "pglab",
tr("Close all windows?"), QMessageBox::Yes, QMessageBox::No);
if (res == QMessageBox::Yes && m_masterController->confirmQuit(this)) {
if (res == QMessageBox::Yes) {
QApplication::quit();
}
}

View file

@ -20,13 +20,10 @@
#include <QStandardPaths>
#include <QStatusBar>
#include <QTableView>
#include <QApplication>
#include <QInputDialog>
#include "EditTableWidget.h"
#include "CodeGenerator.h"
#include "querytool/QueryTool.h"
#include "querytool/UnsavedDocumentsDialog.h"
#include <serverinspector/ServerInspector.h>
namespace pg = Pgsql;
@ -94,18 +91,12 @@ void DatabaseWindow::closeEvent(QCloseEvent *event)
{
for (int idx = 0; idx < ui->mainTabs->count(); ++idx)
{
auto mp = dynamic_cast<ManagedPage*>(ui->mainTabs->widget(idx));
if (mp && !dynamic_cast<QueryTool*>(mp) && !mp->CanClose(true))
if (!canCloseTab(idx))
{
event->ignore();
return;
}
}
auto docs = documentsClosedWith(m_masterController->sqlDocumentRegistry().modifiedDocuments(),
[this](const QWidget *editor) { return editor->window() == this; });
if (!confirmClose(this, docs))
event->ignore();
}
}
void DatabaseWindow::setConfig(const ConnectionConfig &config)
@ -208,14 +199,14 @@ void DatabaseWindow::openSqlFile(QString file_name)
{
if (!file_name.isEmpty())
{
auto document = std::make_shared<SqlDocument>();
if (document->load(file_name))
auto *ct = new QueryTool(this, this);
if (ct->load(file_name))
{
m_masterController->sqlDocumentRegistry().add(document);
auto *ct = new QueryTool(this, document, this);
ct->addAction(ui->actionExecute_query);
addPage(ct, ct->title());
}
else
delete ct;
}
}
@ -400,53 +391,13 @@ void DatabaseWindow::on_actionInspect_server_triggered()
newServerInspectorPage();
}
void DatabaseWindow::openSharedSqlDocument(std::shared_ptr<SqlDocument> document)
{
auto *ct = new QueryTool(this, std::move(document), this);
ct->addAction(ui->actionExecute_query);
addPage(ct, ct->title());
ct->updateTitle();
}
void DatabaseWindow::on_actionNew_Query_triggered()
{
auto document = std::make_shared<SqlDocument>();
m_masterController->sqlDocumentRegistry().add(document);
auto *ct = new QueryTool(this, document, this);
auto *ct = new QueryTool(this, this);
ct->addAction(ui->actionExecute_query);
addPage(ct, "new");
}
ct->newdoc();
void DatabaseWindow::on_actionOpen_in_other_window_triggered()
{
auto ct = dynamic_cast<QueryTool*>(ui->mainTabs->currentWidget());
if (!ct)
return;
QList<DatabaseWindow*> others;
for (auto *widget : QApplication::topLevelWidgets()) {
auto dw = qobject_cast<DatabaseWindow*>(widget);
if (dw && dw != this && dw->isVisible())
others.append(dw);
}
if (others.isEmpty()) {
QMessageBox::information(this, "pglab", tr("No other window is open."));
return;
}
QStringList names;
for (int i = 0; i < others.size(); ++i)
names.append(QString("%1 (%2)").arg(others[i]->windowTitle()).arg(i + 1));
bool ok = false;
QString selected = QInputDialog::getItem(this, tr("Open in other window"),
tr("Select window:"), names, 0, false, &ok);
if (!ok)
return;
auto target = others[names.indexOf(selected)];
target->openSharedSqlDocument(ct->document());
}
void DatabaseWindow::on_actionOpen_Query_triggered()

View file

@ -6,7 +6,6 @@
#include "OpenDatabase.h"
#include "Pgsql_Connection.h"
#include "IDatabaseWindow.h"
#include "querytool/SqlDocument.h"
#include <QMainWindow>
#include <memory>
@ -39,7 +38,6 @@ public:
~DatabaseWindow();
void setConfig(const ConnectionConfig &config);
void openSharedSqlDocument(std::shared_ptr<SqlDocument> document);
std::shared_ptr<OpenDatabase> getDatabase() { return m_database; }
@ -100,7 +98,6 @@ private slots:
void on_actionInspect_server_triggered();
void on_actionNew_Query_triggered();
void on_actionOpen_Query_triggered();
void on_actionOpen_in_other_window_triggered();
void on_actionSave_Query_triggered();
void on_actionPaste_lang_string_triggered();
void on_actionRefreshCatalog_triggered();

View file

@ -43,7 +43,6 @@
<addaction name="actionSave_Query"/>
<addaction name="actionSave_query_as"/>
<addaction name="actionSave_copy_of_query_as"/>
<addaction name="actionOpen_in_other_window"/>
<addaction name="separator"/>
<addaction name="actionSave_connection"/>
<addaction name="actionExport_data"/>
@ -107,11 +106,6 @@
<addaction name="menuWindow"/>
<addaction name="menuHelp"/>
</widget>
<action name="actionOpen_in_other_window">
<property name="text">
<string>Open in other window / Duplicate in window...</string>
</property>
</action>
<action name="actionAbout">
<property name="icon">
<iconset resource="resources.qrc">

View file

@ -1,6 +1,5 @@
#include "MasterController.h"
#include "ConnectionController.h"
#include "querytool/UnsavedDocumentsDialog.h"
#include "utils/PostgresqlUrlParser.h"
#include <ConnectionConfig.h>
#include <QCoreApplication>
@ -60,9 +59,4 @@ SQLiteConnection& MasterController::userConfigDatabase()
return m_userConfigDatabase;
}
bool MasterController::confirmQuit(QWidget *parent)
{
return confirmClose(parent, m_sqlDocumentRegistry.modifiedDocuments());
}

View file

@ -7,7 +7,6 @@
#include <map>
#include <memory>
#include "sqlite/SQLiteConnection.h"
#include "querytool/SqlDocumentRegistry.h"
class ConnectionController;
@ -25,10 +24,6 @@ public:
ConnectionController* connectionController();
SQLiteConnection& userConfigDatabase();
SqlDocumentRegistry& sqlDocumentRegistry() { return m_sqlDocumentRegistry; }
/// Asks once about all modified documents of all windows. Returns false when quitting must be aborted.
bool confirmQuit(QWidget *parent);
signals:
@ -36,7 +31,6 @@ public slots:
private:
SQLiteConnection m_userConfigDatabase;
SqlDocumentRegistry m_sqlDocumentRegistry;
ConnectionController* m_connectionController = nullptr;
};

View file

@ -1,20 +1,12 @@
#include "SslModeModel.h"
#include <QStringList>
namespace {
const QStringList sslModes = {QString(), "disable", "allow", "prefer", "require", "verify-ca", "verify-full"};
}
SslModeModel::SslModeModel(QObject *parent)
: QAbstractListModel(parent)
{}
int SslModeModel::rowCount(const QModelIndex &parent) const
int SslModeModel::rowCount(const QModelIndex &) const
{
return parent.isValid() ? 0 : static_cast<int>(sslModes.size());
return 6;
}
int SslModeModel::columnCount(const QModelIndex &) const
@ -24,43 +16,49 @@ int SslModeModel::columnCount(const QModelIndex &) const
QVariant SslModeModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.model() != this || index.row() < 0 || index.row() >= rowCount()
|| index.column() < 0 || index.column() >= ColCount)
return {};
if (index.column() == Name) {
const auto &mode = sslModes.at(index.row());
if (role == Qt::UserRole)
return mode;
if (role == Qt::DisplayRole || role == Qt::EditRole)
return index.row() == 0 ? tr("Default") : mode;
return {};
}
QVariant v;
if (role == Qt::DisplayRole) {
switch (index.column()) {
case Name:
switch(index.row()) {
case 0:
v = tr("disable");
break;
case 1:
v = tr("allow");
break;
case 2:
v = tr("prefer");
break;
case 3:
v = tr("require");
break;
case 4:
v = tr("verify_ca");
break;
case 5:
v = tr("verify_full");
break;
}
break;
case Description:
switch(index.row()) {
case 0:
v = tr("use the PostgreSQL default SSL mode");
break;
case 1:
v = tr("try a non encrypted connection only");
break;
case 2:
case 1:
v = tr("try no encryption first then try encrypted");
break;
case 3:
case 2:
v = tr("try encrypted first then not encrypted");
break;
case 4:
case 3:
v = tr("require an encrypted connection");
break;
case 5:
case 4:
v = tr("verify encryption certificate has a valid signature");
break;
case 6:
case 5:
v = tr("verify encryption certificate has a valid signature and matches the host");
break;
}

View file

@ -1,7 +1,5 @@
#include "UserConfiguration.h"
#include <QFont>
#include <QFontDatabase>
#include <QFontInfo>
UserConfiguration* UserConfiguration::instance()
{
@ -16,19 +14,12 @@ UserConfiguration::UserConfiguration()
QFont UserConfiguration::codeFont() const
{
QString family = m_settings.value("fonts/code/family").toString();
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
font.setStyleHint(QFont::Monospace);
QString family = m_settings.value("fonts/code/family", QString("Source Code Pro")).toString();
int size = m_settings.value("fonts/code/size", 10).toInt();
QFont font;
font.setFamily(family);
font.setFixedPitch(true);
if (!family.isEmpty())
font.setFamily(family);
else if (!QFontInfo(font).fixedPitch())
font.setFamilies({ "Source Code Pro", "Consolas", "Courier New", "monospace" });
bool ok = false;
int size = m_settings.value("fonts/code/size").toInt(&ok);
if (ok && size > 0)
font.setPointSize(size);
font.setPointSize(size);
return font;
}

View file

@ -1,6 +1,5 @@
#include "CodeEditor.h"
#include "EditorGutter.h"
#include <QKeyEvent>
#include <QPainter>
#include <QTextBlock>
#include <QDebug>
@ -125,125 +124,9 @@ bool CodeEditor::lineHasError(int blockNumber) const
return errorLines.count(blockNumber) > 0;
}
void CodeEditor::setCompletionCallback(EditorCompletionCallback callback)
{
m_completionCallback = std::move(callback);
if (!m_completionCallback)
hideCompletions();
}
void CodeEditor::showCompletions()
{
updateCompletions(true);
}
void CodeEditor::hideCompletions()
{
if (m_completionPopup)
m_completionPopup->hide();
}
bool CodeEditor::updateCompletions(bool explicitRequest)
{
if (!m_completionCallback) {
hideCompletions();
return false;
}
const int pos = textCursor().position();
EditorCompletionResult result = m_completionCallback(toPlainText(), pos);
bool show = !result.items.empty();
if (show && !explicitRequest) {
// do not bother the user while nothing identifier like has been typed
if (result.replaceLen == 0 && !result.hasQualifier)
show = false;
// the only candidate is what has been typed already
else if (result.items.size() == 1 && result.replaceLen > 0) {
QTextCursor c = textCursor();
c.setPosition(result.replaceStart);
c.setPosition(result.replaceStart + result.replaceLen, QTextCursor::KeepAnchor);
if (c.selectedText() == result.items[0].insertText)
show = false;
}
}
if (!show) {
hideCompletions();
return false;
}
if (!m_completionPopup) {
m_completionPopup = new CompletionPopup(this);
connect(m_completionPopup, &CompletionPopup::itemAccepted, this, &CodeEditor::acceptCompletion);
}
m_completionResult = std::move(result);
m_completionPopup->setFont(font());
m_completionPopup->setItems(m_completionResult.items);
QTextCursor c = textCursor();
c.setPosition(m_completionResult.replaceStart);
QRect r = cursorRect(c);
m_completionPopup->showAt(viewport()->mapToGlobal(r.bottomLeft()));
return true;
}
void CodeEditor::acceptCompletion(int row)
{
if (row < 0 || row >= static_cast<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)
{
auto k = e->key();
const bool popupVisible = m_completionPopup && m_completionPopup->isVisible();
if (popupVisible) {
switch (k) {
case Qt::Key_Down:
m_completionPopup->moveSelection(1);
return;
case Qt::Key_Up:
m_completionPopup->moveSelection(-1);
return;
case Qt::Key_PageDown:
m_completionPopup->moveSelection(10);
return;
case Qt::Key_PageUp:
m_completionPopup->moveSelection(-10);
return;
case Qt::Key_Return:
case Qt::Key_Enter:
case Qt::Key_Tab:
acceptCompletion(m_completionPopup->currentRow());
return;
case Qt::Key_Escape:
hideCompletions();
return;
}
}
if (k == Qt::Key_Space && e->modifiers().testFlag(Qt::ControlModifier)) {
showCompletions();
return;
}
switch (k) {
case Qt::Key_Tab:
if (indentSelection(true))
@ -272,20 +155,6 @@ void CodeEditor::keyPressEvent(QKeyEvent *e)
break;
}
QPlainTextEdit::keyPressEvent(e);
if (m_completionCallback) {
if (popupVisible) {
if (k == Qt::Key_Backspace || k == Qt::Key_Delete || !e->text().isEmpty())
updateCompletions(false);
else if (k == Qt::Key_Left || k == Qt::Key_Right || k == Qt::Key_Home || k == Qt::Key_End)
hideCompletions();
}
else if (!e->text().isEmpty() && !e->modifiers().testFlag(Qt::ControlModifier)) {
const QChar ch = e->text()[0];
if (ch.isLetterOrNumber() || ch == QLatin1Char('_') || ch == QLatin1Char('.') || ch == QLatin1Char('"'))
updateCompletions(false);
}
}
}

View file

@ -4,7 +4,6 @@
#include <QPlainTextEdit>
#include <set>
#include "util/Colors.h"
#include "CompletionPopup.h"
/** This class adds some capabilities to QPlainTextEdit that are useful
* in code editor scenarios.
@ -21,16 +20,9 @@ public:
void clearErrorMarkers();
void setFont(const QFont &);
void setTabSize(int chars);
/// Install the function that supplies completions, an empty function disables completion
void setCompletionCallback(EditorCompletionCallback callback);
/// Show completions at the cursor, also when nothing has been typed yet
void showCompletions();
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *e) override;
void focusOutEvent(QFocusEvent *e) override;
void mousePressEvent(QMouseEvent *e) override;
signals:
@ -52,14 +44,6 @@ private:
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
int m_tabSize = 0; // tabSize in characters
bool m_useTab = false;

View file

@ -1,112 +0,0 @@
#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

@ -1,40 +0,0 @@
#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

@ -6,6 +6,7 @@
#include "catalog/PgConstraintContainer.h"
#include "catalog/PgClassContainer.h"
#include "SqlFormattingUtils.h"
#include "Pgsql_oids.h"
#include "CustomDataRole.h"
#include <QtConcurrent>
#include <QFuture>
@ -97,46 +98,45 @@ Oid CrudModel::getType(int column) const
CrudModel::Value CrudModel::getLatestData(int columnIndex, int rowIndex) const
{
if (!m_roData)
return {};
const RowMapping &row_mapping = m_rowMapping[rowIndex];
if (row_mapping.pending)
if (m_roData)
{
// an edited cell can be NULL, that is different from not having been edited
if (auto edited = m_pendingRowList.getValue(columnIndex, row_mapping.rowKey))
return *edited;
auto row_mapping = m_rowMapping[rowIndex];
std::optional<Value> val;
if (row_mapping.pending)
val = m_pendingRowList.getValue(columnIndex, row_mapping.rowKey);
if (!val.has_value())
val = getSavedData(row_mapping, columnIndex);
return val.value_or(Value());
}
return getSavedData(row_mapping, columnIndex);
return {};
}
CrudModel::Value CrudModel::getSavedData(int columnIndex, int rowIndex) const
{
if (m_roData)
{
auto row_mapping = m_rowMapping[rowIndex];
return getSavedData(row_mapping, columnIndex);
}
return {};
}
CrudModel::Value CrudModel::getSavedData(const RowMapping &row_mapping, int columnIndex) const
{
// rows that have been saved (or are new) have their own copy of the data
if (row_mapping.isModified())
return row_mapping.modifiedValue(columnIndex);
std::optional<Value> val;
if (!val.has_value() && row_mapping.isModified())
val = row_mapping.modifiedValue(columnIndex);
if (row_mapping.rowKey < m_roData->rows() && !m_roData->null(columnIndex, row_mapping.rowKey))
return std::string(m_roData->val(columnIndex, row_mapping.rowKey));
if (!val.has_value() && row_mapping.rowKey < m_roData->rows())
if (!m_roData->null(columnIndex, row_mapping.rowKey))
val = std::string(m_roData->val(columnIndex, row_mapping.rowKey));
return {};
return val.value_or(Value());
}
CrudModel::RowMapping* CrudModel::findRowMapping(int rowKey)
{
auto iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(),
[rowKey](const RowMapping &rhs) { return rhs.rowKey == rowKey; });
return iter != m_rowMapping.end() ? &*iter : nullptr;
}
void CrudModel::updatePendingState(bool was_dirty)
{
if (was_dirty != hasPendingChanges())
emit pendingChangesChanged(hasPendingChanges());
}
namespace {
QVariant StringToVariant(const std::string &str, Oid oid)
{
Pgsql::Value v(str.c_str(), oid);
@ -174,9 +174,8 @@ QVariant StringToVariant(const std::string &str, Oid oid)
case Pgsql::float8_oid:
return v.operator double();
}
}
} // namespace
}
QVariant CrudModel::data(const QModelIndex &index, int role) const
{
@ -255,7 +254,8 @@ void CrudModel::loadIntoModel(std::shared_ptr<Pgsql::Result> data)
if (m_wholeTable)
appendNewRowInternal();
endResetModel();
updatePendingState(was_dirty);
if (was_dirty)
emit pendingChangesChanged(false);
}
void CrudModel::initRowMapping()
@ -269,11 +269,26 @@ void CrudModel::initRowMapping()
void CrudModel::connectionStateChanged()
{
if (m_dbConn.state() == ASyncDBConnection::State::Connected && callLoadData)
{
callLoadData = false;
loadData();
}
switch (m_dbConn.state()) {
case ASyncDBConnection::State::NotConnected:
break;
case ASyncDBConnection::State::Connecting:
break;
case ASyncDBConnection::State::Connected:
if (callLoadData)
{
callLoadData = false;
loadData();
}
break;
case ASyncDBConnection::State::QuerySend:
break;
case ASyncDBConnection::State::CancelSend:
break;
case ASyncDBConnection::State::Terminating:
break;
}
}
Qt::ItemFlags CrudModel::flags(const QModelIndex &index) const
@ -311,7 +326,8 @@ bool CrudModel::setData(const QModelIndex &index, const QVariant &value, int rol
}
bool was_dirty = hasPendingChanges();
m_pendingRowList.setValue(col, row_mapping.rowKey, val);
updatePendingState(was_dirty);
if (!was_dirty)
emit pendingChangesChanged(true);
emit dataChanged(index, index, QVector<int>() << role);
return true;
@ -321,7 +337,8 @@ bool CrudModel::setData(const QModelIndex &index, const QVariant &value, int rol
std::tuple<bool, std::vector<CrudModel::Value>> CrudModel::saveRow(const PendingRow &pending_row, const RowMapping &row_mapping)
{
if (pending_row.data().empty())
auto data = pending_row.data();
if (data.empty())
return { true, {} };
if (row_mapping.isNew())
@ -331,16 +348,31 @@ std::tuple<bool, std::vector<CrudModel::Value>> CrudModel::saveRow(const Pending
std::tie(buffer, params) = createInsertQuery(pending_row);
Pgsql::Connection db_update_conn;
connectForUpdate(db_update_conn);
auto dbconfig = m_database->config();
db_update_conn.connect(dbconfig.connectionString());
try
{
auto result = db_update_conn.queryParam(buffer, params);
if (result && result.rows() == 1)
return { true, rowValues(*result.begin()) };
{
std::vector<Value> values;
auto row = *result.begin();
for (auto v : row)
{
if (v.null())
values.push_back(Value());
else
values.push_back(std::string(v.c_str()));
}
return { true, values };
}
}
catch (const Pgsql::PgResultError &ex)
{
reportError(ex.what());
QMessageBox msgBox;
msgBox.setText(ex.what());
msgBox.exec();
}
return { false, {} };
}
@ -357,7 +389,8 @@ std::tuple<bool, std::vector<CrudModel::Value>> CrudModel::saveUpdate(const Pend
try
{
Pgsql::Connection db_update_conn;
connectForUpdate(db_update_conn);
auto dbconfig = m_database->config();
db_update_conn.connect(dbconfig.connectionString());
auto tx = Pgsql::Transaction::startTransaction(db_update_conn);
for (Oid table_oid : tables)
{
@ -385,47 +418,32 @@ std::tuple<bool, std::vector<CrudModel::Value>> CrudModel::saveUpdate(const Pend
}
catch (const Pgsql::PgResultError &ex)
{
reportError(ex.what());
QMessageBox msgBox;
msgBox.setText(ex.what());
msgBox.exec();
}
return { false, {} };
}
void CrudModel::connectForUpdate(Pgsql::Connection &connection) const
{
auto dbconfig = m_database->config();
connection.connect(dbconfig.connectionString());
}
void CrudModel::reportError(const QString &message) const
{
QMessageBox msgBox;
msgBox.setText(message);
msgBox.exec();
}
std::vector<CrudModel::Value> CrudModel::rowValues(const Pgsql::Row &row)
{
std::vector<Value> values;
for (auto v : row)
{
if (v.null())
values.push_back(Value());
else
values.push_back(std::string(v.c_str()));
}
return values;
}
QString CrudModel::tableName(Oid table_oid) const
{
auto table = m_database->catalog()->classes()->getByKey(table_oid);
return table ? table->fullyQualifiedQuotedObjectName() : QString();
}
std::vector<int> CrudModel::resultColumnsOfTable(Oid table_oid) const
{
std::vector<int> cols;
for (int col = 0; col < m_roData->cols(); ++col)
if (m_roData->ftable(col) == table_oid && m_roData->ftableCol(col) > 0)
cols.push_back(col);
return cols;
}
QString CrudModel::attributeName(int col) const
{
if (col < static_cast<int>(m_columnList.size()) && m_columnList[col])
return m_columnList[col]->name;
if (col < static_cast<int>(columnList.size()) && columnList[col])
return columnList[col]->name;
return QString::fromUtf8(m_roData->getColName(col));
}
@ -475,7 +493,7 @@ std::tuple<QString, Pgsql::Params> CrudModel::createUpdateQuery(Oid table_oid, c
std::tuple<QString, Pgsql::Params> CrudModel::createInsertQuery(const PendingRow &pending_row)
{
Pgsql::Params params;
const auto &data = pending_row.data();
auto data = pending_row.data();
QString table_name = m_table->fullyQualifiedQuotedObjectName();
QString buffer;
QTextStream q(&buffer);
@ -505,6 +523,7 @@ std::tuple<QString, Pgsql::Params> CrudModel::createInsertQuery(const PendingRow
QString CrudModel::createDeleteStatement() const
{
Pgsql::Params params;
QString table_name = m_table->fullyQualifiedQuotedObjectName();
QString buffer;
QTextStream q(&buffer);
@ -528,25 +547,40 @@ bool CrudModel::savePendingChanges()
while (!m_pendingRowList.m_rows.empty())
{
auto iter = m_pendingRowList.m_rows.begin();
RowMapping *mapping = findRowMapping(iter->first);
if (!mapping)
int pendingKey = iter->first;
auto mapping_iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(),
[pendingKey](const RowMapping &rhs) -> bool { return rhs.rowKey == pendingKey; });
if (mapping_iter == m_rowMapping.end())
return false;
auto [ok, modified_row] = saveRow(iter->second, *mapping);
if (!ok)
return false;
auto [ok, modified_row] = saveRow(iter->second, *mapping_iter);
if (ok)
{
int rowKey = iter->first;
m_pendingRowList.m_rows.erase(iter);
m_pendingRowList.m_rows.erase(iter);
mapping->setModifiedRowData(modified_row);
mapping->pending = false;
// appending invalidates mapping, so keep this last
if (mapping == &m_rowMapping.back())
appendNewRow();
auto iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(),
[rowKey](const RowMapping &rhs) -> bool { return rhs.rowKey == rowKey; });
if (iter != m_rowMapping.end())
{
iter->setModifiedRowData(modified_row);
iter->pending = false;
if (IsLastRow(iter))
appendNewRow();
}
}
else
return false;
}
return true;
}
bool CrudModel::IsLastRow(RowMappingVector::iterator mapping_iter) const
{
return mapping_iter == --m_rowMapping.end();
}
bool CrudModel::columnIsReadOnly(int column_index) const
{
if (m_roData == nullptr)
@ -556,30 +590,30 @@ bool CrudModel::columnIsReadOnly(int column_index) const
if (!m_mapping || !m_mapping->isEditable(col))
return true;
const auto &column = m_columnList[col];
const auto &column = columnList[col];
return !column || column->getIdentity() == PgAttribute::Identity::Always
|| column->getGenerated() != PgAttribute::Generated::None;
}
void CrudModel::initializeColumnList()
{
m_columnList.clear();
m_columnList.reserve(m_roData->cols());
columnList.clear();
columnList.reserve(m_roData->cols());
auto atts = m_database->catalog()->attributes();
for (int col = 0; col < m_roData->cols(); ++col)
{
int attnum = m_roData->ftableCol(col);
if (attnum <= 0)
{
m_columnList.emplace_back();
columnList.emplace_back();
continue;
}
auto columns = atts->getColumnsForRelation(m_roData->ftable(col));
auto find_result = std::find_if(columns.begin(), columns.end(), [attnum](const PgAttribute &att) { return att.num == attnum; });
if (find_result != columns.end())
m_columnList.emplace_back(*find_result);
columnList.emplace_back(*find_result);
else
m_columnList.emplace_back();
columnList.emplace_back();
}
}
@ -587,7 +621,8 @@ bool CrudModel::saveChanges()
{
bool was_dirty = hasPendingChanges();
bool ok = savePendingChanges();
updatePendingState(was_dirty);
if (was_dirty != hasPendingChanges())
emit pendingChangesChanged(hasPendingChanges());
return ok;
}
@ -629,42 +664,49 @@ std::tuple<bool, QString> CrudModel::removeRows(const std::set<IntegerRange<int>
return { false, "Range error" };
if (!m_wholeTable || !m_table || m_mapping->primaryKeyColumns(m_table->oid()).empty())
return { false, "Rows can only be removed from a table with a primary key" };
// When removing rows there is no direct mapping anymore between the rows in the grid
// and the rows in m_roData
// Therefor we need an indirection to keep track of which rows are visible and which
// grid row maps to what data row. Maybe we can also keep track where the current data
// of the row is located original data, pending data or modified data
// 1. Get PKEY and remove that row from table
// When removing rows there is no direct mapping anymore between the rows in the grid
// and the rows in m_roData, that is why the grid rows are mapped through m_rowMapping.
try
{
deleteRowsFromDatabase(row_ranges);
// If something goes wrong after the commit we should reload contents of model
Pgsql::Connection db_update_conn;
auto dbconfig = m_database->config();
db_update_conn.connect(dbconfig.connectionString());
// First delete rows in table
QString delete_statement = createDeleteStatement();
{
auto tx = Pgsql::Transaction::startTransaction(db_update_conn);
for (auto range : row_ranges)
{
for (int current_row = range.start(); current_row < range.end(); ++current_row)
{
auto&& mapping = m_rowMapping[static_cast<size_t>(current_row)];
auto params = getPKeyParamsForRow(mapping, m_table->oid());
if (!params.empty())
db_update_conn.queryParam(delete_statement, params);
}
}
tx.commit();
// If something goes wrong after this commit we should reload contents of model
}
// Then from model
bool was_dirty = hasPendingChanges();
removeRangesFromModel(row_ranges);
updatePendingState(was_dirty);
return { true, "" };
RemoveRangesOfRowsFromModel(row_ranges);
if (was_dirty != hasPendingChanges())
emit pendingChangesChanged(hasPendingChanges());
return { true, "" };
}
catch (const Pgsql::PgResultError &error)
{
return { false, QString::fromUtf8(error.details().messageDetail.c_str()) };
}
}
void CrudModel::deleteRowsFromDatabase(const std::set<IntegerRange<int>> &row_ranges)
{
Pgsql::Connection db_update_conn;
connectForUpdate(db_update_conn);
QString delete_statement = createDeleteStatement();
auto tx = Pgsql::Transaction::startTransaction(db_update_conn);
for (const auto &range : row_ranges)
{
for (int current_row = range.start(); current_row < range.end(); ++current_row)
{
const auto &mapping = m_rowMapping[static_cast<size_t>(current_row)];
auto params = getPKeyParamsForRow(mapping, m_table->oid());
if (!params.empty())
db_update_conn.queryParam(delete_statement, params);
}
}
tx.commit();
return { false, QString::fromUtf8(error.details().messageDetail.c_str()) };
}
}
bool CrudModel::removeRows(int row, int count, const QModelIndex &)
@ -677,18 +719,18 @@ bool CrudModel::removeRows(int row, int count, const QModelIndex &)
return res;
}
void CrudModel::removeRangesFromModel(const std::set<IntegerRange<int>> &row_ranges)
void CrudModel::RemoveRangesOfRowsFromModel(const std::set<IntegerRange<int>> &row_ranges)
{
int rows_deleted = 0;
for (auto range : row_ranges)
{
range.setStart(range.start() - rows_deleted);
removeRangeFromModel(range);
RemoveRangeOfRowsFromModel(range);
rows_deleted += range.length();
}
}
void CrudModel::removeRangeFromModel(IntegerRange<int> range)
void CrudModel::RemoveRangeOfRowsFromModel(IntegerRange<int> range)
{
beginRemoveRows(QModelIndex(), range.start(), range.end() - 1);
SCOPE_EXIT { endRemoveRows(); };

View file

@ -31,9 +31,6 @@ public:
/// Show an arbitrary query result, cells are editable where the result maps them to a table with a primary key
void setResult(std::shared_ptr<OpenDatabase> db, std::shared_ptr<Pgsql::Result> result);
/// Number of grid columns in front of the result columns (the row number column)
static constexpr int PreColumnCount = 1;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
@ -63,8 +60,13 @@ signals:
/// Emitted when the model goes from clean to having pending changes or back
void pendingChangesChanged(bool hasChanges);
public slots:
private:
const int PreColumnCount = 1;
class ColumnSort {
public:
enum Direction { Ascending, Descending };
@ -123,6 +125,17 @@ private:
public:
using Map = std::map<int, PendingRow>;
PendingRow& getRow(int row)
{
auto iter = m_rows.lower_bound(row);
if (iter != m_rows.end() && iter->first == row) {
return iter->second;
}
else {
return m_rows.insert(iter, {row, PendingRow(row)})->second;
}
}
void setValue(int col, int row, const Value &value)
{
auto iter = m_rows.find(row);
@ -213,7 +226,13 @@ private:
std::shared_ptr<Pgsql::Result> m_roData;
std::vector<std::optional<PgAttribute>> m_columnList; // attribute per column in m_roData, empty for expression columns
struct ColumnData
{
std::string name;
// identity allways or generated
};
std::vector<std::optional<PgAttribute>> columnList; // attribute per column in m_roData, empty for expression columns
PendingRowList m_pendingRowList;
@ -223,64 +242,47 @@ private:
/// call on initial load to fill in the mappings
void initRowMapping();
/// Value as shown in the grid, pending edits take precedence over saved data
Value getLatestData(int column, int row) const;
Value getLatestData(const QModelIndex &index) const
{
return getLatestData(index.column() - PreColumnCount, index.row());
}
/// Value as last loaded from or saved to the database
Value getSavedData(int columnIndex, int rowIndex) const;
Value getSavedData(const RowMapping &row_mapping, int columnIndex) const;
/// Mapping of the row with the given key, nullptr when the row is not in the grid (anymore)
RowMapping* findRowMapping(int rowKey);
/// Call after changing the pending rows to emit pendingChangesChanged when the state toggled
void updatePendingState(bool was_dirty);
Oid getType(int column) const;
// ---- Saving, all of these only use the data of the pending row and the row mapping
/// Get the PKey values from when the row was last restored or retrieved.
Pgsql::Params getPKeyParamsForRow(const RowMapping &row_mapping, Oid table_oid) const;
bool savePendingChanges();
/// Inserts the row when it is new otherwise updates it. Returns the values of the row as stored in the database.
std::tuple<bool, std::vector<Value>> saveRow(const PendingRow &pending_row, const RowMapping &row_mapping);
std::tuple<bool, std::vector<Value>> saveUpdate(const PendingRow &pending_row, const RowMapping &row_mapping);
/// Get the PKey values from when the row was last restored or retrieved.
Pgsql::Params getPKeyParamsForRow(const RowMapping &row_mapping, Oid table_oid) const;
std::tuple<QString, Pgsql::Params> createUpdateQuery(Oid table_oid, const PendingRow &pending_row, const RowMapping &row_mapping);
std::tuple<QString, Pgsql::Params> createUpdateQuery(Oid table_oid, const PendingRow &pending_row, const RowMapping &row_mapping);
std::tuple<QString, Pgsql::Params> createInsertQuery(const PendingRow &pending_row);
QString createDeleteStatement() const;
/// Opens a separate connection for writing so edits don't interfere with the loading connection
void connectForUpdate(Pgsql::Connection &connection) const;
/// Tells the user about a failed write, the one place where the model shows a dialog
void reportError(const QString &message) const;
static std::vector<Value> rowValues(const Pgsql::Row &row);
// ---- Rows
std::tuple<bool, std::vector<Value>> saveRow(const PendingRow &pending_row, const RowMapping &row_mapping);
std::tuple<bool, std::vector<Value>> saveUpdate(const PendingRow &pending_row, const RowMapping &row_mapping);
void appendNewRow();
void appendNewRowInternal();
void appendNewRowInternal();
int lastRowKey = -1;
int lastRowKey = -1;
int allocNewRowKey() { return ++lastRowKey; }
/// Deletes the rows from the database in one transaction, throws on failure
void deleteRowsFromDatabase(const std::set<IntegerRange<int>> &row_ranges);
/// Removes the rows from the grid and drops their pending edits
void removeRangesFromModel(const std::set<IntegerRange<int>> &row_ranges);
void removeRangeFromModel(IntegerRange<int> row_range);
// ---- Table and column information
/// Name of the table with the given oid, quoted and schema qualified
QString tableName(Oid table_oid) const;
/// Result columns that are user columns (attnum > 0) of the given table, in result order
std::vector<int> resultColumnsOfTable(Oid table_oid) const;
/// Catalog name of the attribute behind a result column, falls back to the result column name
QString attributeName(int col) const;
bool columnIsReadOnly(int column_index) const;
void initializeColumnList();
void RemoveRangesOfRowsFromModel(const std::set<IntegerRange<int>> &row_ranges);
void RemoveRangeOfRowsFromModel(IntegerRange<int> row_range);
bool IsLastRow(RowMappingVector::iterator mapping_iter) const;
bool columnIsReadOnly(int column_index) const;
void initializeColumnList();
private slots:
void loadIntoModel(std::shared_ptr<Pgsql::Result> data);

View file

@ -1,13 +1,10 @@
#include "DataGridWidget.h"
#include "CrudModel.h"
#include "CustomDataRole.h"
#include "RecordPanel.h"
#include "ResultTableModelUtil.h"
#include "util/PgLabItemDelegate.h"
#include <QAction>
#include <QItemSelectionModel>
#include <QSortFilterProxyModel>
#include <QSplitter>
#include <QStyle>
#include <QTableView>
#include <QToolBar>
@ -39,28 +36,12 @@ DataGridWidget::DataGridWidget(QWidget *parent)
m_toolbar->addAction(m_actRevert);
connect(m_actRevert, &QAction::triggered, this, &DataGridWidget::onRevert);
m_actRecord = new QAction(tr("Record"), this);
m_actRecord->setCheckable(true);
m_actRecord->setChecked(true);
m_actRecord->setToolTip(tr("Show the values of the current row next to the grid"));
m_toolbar->addAction(m_actRecord);
m_view = new QTableView(this);
layout->addWidget(m_view);
SetTableViewDefault(m_view);
auto delegate = new PgLabItemDelegate(m_view);
m_view->setItemDelegate(delegate);
m_splitter = new QSplitter(Qt::Horizontal, this);
layout->addWidget(m_splitter);
m_splitter->addWidget(m_view);
m_recordPanel = new RecordPanel(this);
m_recordPanel->setFirstDataColumn(CrudModel::PreColumnCount);
m_splitter->addWidget(m_recordPanel);
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 0);
connect(m_actRecord, &QAction::toggled, m_recordPanel, &QWidget::setVisible);
}
void DataGridWidget::setSourceModel(QAbstractItemModel *model)
@ -70,18 +51,13 @@ void DataGridWidget::setSourceModel(QAbstractItemModel *model)
m_proxy->setSortRole(CustomSortRole);
}
disconnect(m_pendingChangesConnection);
disconnect(m_currentRowConnection);
m_proxy->setSourceModel(model);
m_view->setModel(m_proxy);
m_view->setSortingEnabled(true);
m_proxy->sort(0, Qt::AscendingOrder);
m_recordPanel->setModel(m_proxy);
m_currentRowConnection = connect(m_view->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &DataGridWidget::onCurrentRowChanged);
auto crud = qobject_cast<CrudModel *>(model);
m_actSave->setVisible(crud != nullptr);
m_actRevert->setVisible(crud != nullptr);
m_toolbar->setVisible(crud != nullptr);
if (crud) {
m_pendingChangesConnection = connect(crud, &CrudModel::pendingChangesChanged, this, &DataGridWidget::onPendingChangesChanged);
onPendingChangesChanged(crud->hasPendingChanges());
@ -91,11 +67,6 @@ void DataGridWidget::setSourceModel(QAbstractItemModel *model)
}
}
void DataGridWidget::onCurrentRowChanged(const QModelIndex &current)
{
m_recordPanel->setCurrentRow(current.row());
}
void DataGridWidget::onPendingChangesChanged(bool hasChanges)
{
m_actSave->setEnabled(hasChanges);

View file

@ -5,12 +5,9 @@
class QAbstractItemModel;
class QAction;
class QModelIndex;
class QSortFilterProxyModel;
class QSplitter;
class QTableView;
class QToolBar;
class RecordPanel;
/** Sortable and editable grid shared by the CRUD tab and the query result tab.
*/
@ -29,7 +26,6 @@ private slots:
void onPendingChangesChanged(bool hasChanges);
void onSave();
void onRevert();
void onCurrentRowChanged(const QModelIndex &current);
private:
/// Closes an open cell editor so its value is written to the model before saving or reverting
@ -37,14 +33,10 @@ private:
QTableView *m_view = nullptr;
QSortFilterProxyModel *m_proxy = nullptr;
QSplitter *m_splitter = nullptr;
RecordPanel *m_recordPanel = nullptr;
QToolBar *m_toolbar = nullptr;
QAction *m_actSave = nullptr;
QAction *m_actRevert = nullptr;
QAction *m_actRecord = nullptr;
QMetaObject::Connection m_pendingChangesConnection;
QMetaObject::Connection m_currentRowConnection;
};
#endif // DATAGRIDWIDGET_H

View file

@ -1,274 +0,0 @@
#include "RecordPanel.h"
#include "CustomDataRole.h"
#include "Pgsql_oids.h"
#include <QEvent>
#include <QFont>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QToolButton>
#include <QVBoxLayout>
namespace {
constexpr int CollapsedTextLines = 2;
constexpr int ExpandedTextLines = 10;
bool isLongTextType(Oid oid)
{
switch (oid) {
case Pgsql::text_oid:
case Pgsql::varchar_oid:
case Pgsql::bpchar_oid:
case Pgsql::json_oid:
case Pgsql::jsonb_oid:
case Pgsql::xml_oid:
return true;
default:
return false;
}
}
int textEditHeight(const QPlainTextEdit *edit, int lines)
{
return edit->fontMetrics().lineSpacing() * lines + 2 * edit->frameWidth() + 6;
}
QString editorText(const QWidget *editor)
{
if (auto line = qobject_cast<const QLineEdit *>(editor))
return line->text();
if (auto plain = qobject_cast<const QPlainTextEdit *>(editor))
return plain->toPlainText();
return {};
}
} // namespace
RecordPanel::RecordPanel(QWidget *parent)
: QScrollArea(parent)
{
setWidgetResizable(true);
}
void RecordPanel::setModel(QAbstractItemModel *model)
{
if (m_model)
m_model->disconnect(this);
m_model = model;
if (m_model) {
connect(m_model, &QAbstractItemModel::dataChanged, this, &RecordPanel::refresh);
connect(m_model, &QAbstractItemModel::layoutChanged, this, &RecordPanel::refresh);
connect(m_model, &QAbstractItemModel::rowsInserted, this, &RecordPanel::refresh);
connect(m_model, &QAbstractItemModel::rowsRemoved, this, &RecordPanel::refresh);
connect(m_model, &QAbstractItemModel::modelReset, this, &RecordPanel::onModelReset);
}
onModelReset();
}
void RecordPanel::setFirstDataColumn(int column)
{
m_firstDataColumn = column;
clearFields();
refresh();
}
void RecordPanel::setCurrentRow(int row)
{
if (m_model && row >= 0 && row < m_model->rowCount())
m_rowIndex = QPersistentModelIndex(m_model->index(row, 0));
else
m_rowIndex = QPersistentModelIndex();
refresh();
}
void RecordPanel::onModelReset()
{
m_rowIndex = QPersistentModelIndex();
refresh();
}
void RecordPanel::refresh()
{
if (!m_model || !m_rowIndex.isValid()) {
clearFields();
return;
}
int columns = m_model->columnCount() - m_firstDataColumn;
if (columns < 0)
columns = 0;
if (m_fields.size() != static_cast<size_t>(columns))
rebuild();
for (auto &field : m_fields) {
QModelIndex index = m_model->index(m_rowIndex.row(), field.column);
bool editable = m_model->flags(index).testFlag(Qt::ItemIsEditable);
QVariant value = index.data(Qt::EditRole);
QString text;
if (value.isValid() && !value.isNull()) {
text = value.toString();
// An empty string is shown as '' so it can be told apart from NULL, the placeholder shows NULL
if (text.isEmpty())
text = QStringLiteral("''");
}
showValue(field, text, editable);
}
}
void RecordPanel::clearFields()
{
m_fields.clear();
if (QWidget *old = takeWidget())
old->deleteLater();
}
void RecordPanel::rebuild()
{
clearFields();
auto content = new QWidget;
auto outer = new QVBoxLayout(content);
outer->setContentsMargins(0, 0, 0, 0);
auto form = new QWidget(content);
auto layout = new QFormLayout(form);
layout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
int row = m_rowIndex.row();
for (int column = m_firstDataColumn; column < m_model->columnCount(); ++column) {
QString header = m_model->headerData(column, Qt::Horizontal, Qt::DisplayRole).toString();
int newline = header.indexOf('\n');
QString name = newline < 0 ? header : header.left(newline);
QString typeName = newline < 0 ? QString() : header.mid(newline + 1);
auto caption = new QWidget(form);
auto captionLayout = new QVBoxLayout(caption);
captionLayout->setContentsMargins(0, 0, 0, 0);
auto nameLabel = new QLabel(name, caption);
QFont nameFont = nameLabel->font();
nameFont.setBold(true);
nameLabel->setFont(nameFont);
auto typeLabel = new QLabel(typeName, caption);
captionLayout->addWidget(nameLabel);
captionLayout->addWidget(typeLabel);
Oid oid = m_model->index(row, column).data(CustomDataTypeRole).toUInt();
FieldRow field;
field.column = column;
QWidget *fieldWidget = nullptr;
if (isLongTextType(oid)) {
auto container = new QWidget(form);
auto containerLayout = new QVBoxLayout(container);
containerLayout->setContentsMargins(0, 0, 0, 0);
auto edit = new QPlainTextEdit(container);
edit->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
edit->setPlaceholderText(tr("null"));
edit->setFixedHeight(textEditHeight(edit, CollapsedTextLines));
edit->installEventFilter(this);
auto expand = new QToolButton(container);
expand->setCheckable(true);
expand->setArrowType(Qt::DownArrow);
expand->setToolTip(tr("Expand or collapse the value"));
connect(expand, &QToolButton::toggled, edit, [edit, expand](bool expanded) {
edit->setFixedHeight(textEditHeight(edit, expanded ? ExpandedTextLines : CollapsedTextLines));
expand->setArrowType(expanded ? Qt::UpArrow : Qt::DownArrow);
});
auto buttonRow = new QHBoxLayout;
buttonRow->addStretch();
buttonRow->addWidget(expand);
containerLayout->addWidget(edit);
containerLayout->addLayout(buttonRow);
field.editor = edit;
field.expandButton = expand;
fieldWidget = container;
}
else {
auto edit = new QLineEdit(form);
edit->setPlaceholderText(tr("null"));
connect(edit, &QLineEdit::editingFinished, this, [this, edit]() { commitEditor(edit); });
field.editor = edit;
fieldWidget = edit;
}
layout->addRow(caption, fieldWidget);
m_fields.push_back(field);
}
outer->addWidget(form);
outer->addStretch(1);
setWidget(content);
}
void RecordPanel::showValue(FieldRow &field, const QString &text, bool editable)
{
QWidget *editor = field.editor;
if (auto line = qobject_cast<QLineEdit *>(editor))
line->setReadOnly(!editable);
else if (auto plain = qobject_cast<QPlainTextEdit *>(editor))
plain->setReadOnly(!editable);
// Do not replace the text while the user is typing in it
if (editor->hasFocus())
return;
if (auto line = qobject_cast<QLineEdit *>(editor))
line->setText(text);
else if (auto plain = qobject_cast<QPlainTextEdit *>(editor))
plain->setPlainText(text);
field.original = text;
}
void RecordPanel::commitEditor(QWidget *editor)
{
if (m_updating || !m_model || !m_rowIndex.isValid())
return;
FieldRow *field = findField(editor);
if (!field)
return;
QString text = editorText(editor);
if (text == field->original)
return;
QModelIndex index = m_model->index(m_rowIndex.row(), field->column);
if (!m_model->flags(index).testFlag(Qt::ItemIsEditable))
return;
m_updating = true;
m_model->setData(index, text, Qt::EditRole);
m_updating = false;
// The model may have rebuilt the fields while storing the value, so look the field up again
if (FieldRow *updated = findField(editor))
updated->original = text;
}
RecordPanel::FieldRow *RecordPanel::findField(QWidget *editor)
{
for (auto &field : m_fields) {
if (field.editor == editor)
return &field;
}
return nullptr;
}
bool RecordPanel::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::FocusOut) {
if (auto edit = qobject_cast<QPlainTextEdit *>(watched))
commitEditor(edit);
}
return QScrollArea::eventFilter(watched, event);
}

View file

@ -1,55 +0,0 @@
#ifndef RECORDPANEL_H
#define RECORDPANEL_H
#include <QScrollArea>
#include <QPersistentModelIndex>
#include <vector>
class QAbstractItemModel;
class QEvent;
class QToolButton;
/** Shows the values of one row of a model as a scrollable list, one line per column.
* Values are edited through the model so the pending change logic of the model is reused.
*/
class RecordPanel : public QScrollArea {
Q_OBJECT
public:
explicit RecordPanel(QWidget *parent = nullptr);
/// Sets the model to show, the columns from firstDataColumn on are shown
void setModel(QAbstractItemModel *model);
/// Columns before this one are skipped, e.g. the row number column of the CRUD model
void setFirstDataColumn(int column);
/// Shows the values of the given row of the model, -1 shows nothing
void setCurrentRow(int row);
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private slots:
void refresh();
void onModelReset();
private:
struct FieldRow {
int column = -1;
QString original; ///< Text the editor shows, only a different text is written back
QWidget *editor = nullptr;
QToolButton *expandButton = nullptr;
};
void clearFields();
void rebuild();
void showValue(FieldRow &field, const QString &text, bool editable);
void commitEditor(QWidget *editor);
FieldRow *findField(QWidget *editor);
QAbstractItemModel *m_model = nullptr;
int m_firstDataColumn = 0;
QPersistentModelIndex m_rowIndex;
std::vector<FieldRow> m_fields;
bool m_updating = false;
};
#endif // RECORDPANEL_H

View file

@ -16,22 +16,17 @@
#include "json/json.h"
#include "OpenDatabase.h"
#include "catalog/PgDatabaseCatalog.h"
#include "SqlCompletionProvider.h"
#include "Pgsql_PgException.h"
#include "QueryParamListController.h"
#include "util.h"
#include "UserConfiguration.h"
#include "IDatabaseWindow.h"
#include "UnsavedDocumentsDialog.h"
#include <QPlainTextDocumentLayout>
#include <QTextDocument>
QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> document, QWidget *parent)
QueryTool::QueryTool(IDatabaseWindow *context, QWidget *parent)
: ManagedPage(parent)
, m_context(context)
, ui(new Ui::QueryTab)
, m_document(std::move(document))
, m_dbConnection()
{
ui->setupUi(this);
@ -43,20 +38,16 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> docu
connect(&m_dbConnection, &ASyncDBConnection::onStateChanged, this, &QueryTool::connectionStateChanged);
connect(&m_dbConnection, &ASyncDBConnection::onNotice, this, &QueryTool::receiveNotice);
const QFont codeFont = UserConfiguration::instance()->codeFont();
m_document->textDocument()->setDefaultFont(codeFont);
ui->queryEdit->setDocument(m_document->textDocument());
ui->queryEdit->setFont(codeFont);
m_document->addEditor(this);
ui->queryEdit->setFont(UserConfiguration::instance()->codeFont());
highlighter = new SqlSyntaxHighlighter(ui->queryEdit->document());
auto types = m_catalog->types();
if (types) {
m_document->highlighter()->setTypes(*types);
highlighter->setTypes(*types);
}
connect(m_document.get(), &SqlDocument::modifiedChanged, this, &QueryTool::updateTitle);
connect(m_document.get(), &SqlDocument::fileNameChanged, this, &QueryTool::updateTitle);
connect(ui->queryEdit, &QPlainTextEdit::textChanged, this, &QueryTool::queryTextChanged);
ui->queryEdit->setAcceptDrops(false);
setupCompletion();
m_queryParamListController = new QueryParamListController(ui->paramTableView, m_context->openDatabase(), this);
connect(ui->addButton, &QPushButton::clicked, m_queryParamListController,
@ -67,73 +58,89 @@ QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> docu
startConnect();
}
void QueryTool::setupCompletion()
{
connect(m_catalog.get(), &PgDatabaseCatalog::refreshed, this, [this]() {
m_completionProvider.reset();
});
ui->queryEdit->setCompletionCallback([this](const QString &text, int pos) {
EditorCompletionResult result;
if (!m_catalog)
return result;
if (!m_completionProvider)
m_completionProvider = std::make_shared<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()
{
// Detach the editor from the shared document, it may be destroyed together with this tool
auto detached = new QTextDocument(ui->queryEdit);
detached->setDocumentLayout(new QPlainTextDocumentLayout(detached));
ui->queryEdit->setDocument(detached);
m_document->removeEditor(this);
delete ui;
}
bool QueryTool::CanClose(bool prompt_user)
{
if (!m_document->isModified())
return true;
bool can_close;
if (m_queryTextChanged) {
if (prompt_user)
can_close = continueWithoutSavingWarning();
else
can_close = false;
}
else {
can_close = true;
}
return can_close;
}
// The document stays open in another editor, so it is not closed with this tool
if (m_document->editors().size() > 1)
return true;
void QueryTool::newdoc()
{
ui->queryEdit->clear();
setFileName(tr("new"));
setQueryTextChanged(false);
m_new = true;
}
if (!prompt_user)
return false;
bool QueryTool::load(const QString &filename)
{
bool result = false;
QFile file(filename);
if (file.open(QIODevice::ReadOnly)) {
QByteArray ba = file.readAll();
auto toUtf16 = QStringDecoder(QStringDecoder::Utf8);
QString text = toUtf16(ba);
if (toUtf16.hasError()) {
file.reset();
QTextStream stream(&file);
text = stream.readAll();
}
return confirmClose(this, {m_document});
ui->queryEdit->setPlainText(text);
setQueryTextChanged(false);
setFileName(filename);
m_new = false;
result = true;
}
return result;
}
bool QueryTool::save()
{
return m_document->save(this);
bool result;
if (m_fileName.isEmpty() || m_new) {
result = saveAs();
}
else {
result = saveSqlTo(m_fileName);
}
return result;
}
bool QueryTool::saveAs()
{
return m_document->saveAs(this);
bool result = false;
QString filename = promptUserForSaveSqlFilename();
if (!filename.isEmpty()) {
result = saveSqlTo(filename);
if (result) {
setFileName(filename);
m_new = false;
}
}
return result;
}
void QueryTool::saveCopyAs()
{
m_document->saveCopyAs(this);
QString filename = promptUserForSaveSqlFilename();
if (!filename.isEmpty()) {
saveSqlTo(filename);
}
}
void QueryTool::execute()
@ -238,21 +245,81 @@ void QueryTool::cancel()
QString QueryTool::title() const
{
QFileInfo fileInfo(m_document->fileName());
QFileInfo fileInfo(m_fileName);
QString fn(fileInfo.fileName());
return fn;
}
void QueryTool::setFileName(const QString &filename)
{
m_fileName = filename;
updateTitle();
}
void QueryTool::updateTitle()
{
QFileInfo fileInfo(m_document->fileName());
QFileInfo fileInfo(m_fileName);
QString title = fileInfo.fileName();
if (m_document->isModified()) {
if (m_queryTextChanged) {
title = "*" + title;
}
m_context->setTitleForWidget(this, title, m_document->fileName());
m_context->setTitleForWidget(this, title, m_fileName);
}
bool QueryTool::continueWithoutSavingWarning()
{
QMessageBox msgBox;
msgBox.setIcon(QMessageBox::Warning);
msgBox.setText(QString("Save changes in document \"%1\" before closing?").arg(m_fileName));
msgBox.setInformativeText("The changes will be lost when you choose Discard.");
msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
msgBox.setDefaultButton(QMessageBox::Cancel);
int ret = msgBox.exec();
if (ret == QMessageBox::Save) {
if (!save()) {
// save failed or was a saveAs and was cancelled, don't close!
ret = QMessageBox::Cancel;
}
}
return ret != QMessageBox::Cancel;
}
bool QueryTool::saveSqlTo(const QString &filename)
{
bool result = false;
QFileInfo fileinfo(filename);
QFile file(filename);
if (file.open(QIODevice::WriteOnly)) {
QTextStream stream(&file);
//stream.setCodec("utf-8");
stream.setEncoding(QStringConverter::Utf8);
QString text = ui->queryEdit->toPlainText();
stream << text;
stream.flush();
if (stream.status() == QTextStream::Ok) {
setQueryTextChanged(false);
result = true;
}
}
return result;
}
QString QueryTool::promptUserForSaveSqlFilename()
{
QString home_dir = QStandardPaths::locate(QStandardPaths::HomeLocation, "", QStandardPaths::LocateDirectory);
return QFileDialog::getSaveFileName(this, tr("Save query"), home_dir, tr("SQL file (*.sql)"));
}
void QueryTool::queryTextChanged()
{
setQueryTextChanged(true);
}
void QueryTool::connectionStateChanged()
@ -551,3 +618,10 @@ void QueryTool::exportData()
exportDataToFilename(file_name);
}
void QueryTool::setQueryTextChanged(bool queryTextChanged)
{
if (m_queryTextChanged != queryTextChanged) {
m_queryTextChanged = queryTextChanged;
updateTitle();
}
}

View file

@ -6,7 +6,6 @@
#include "QueryExplainModel.h"
#include "stopwatch.h"
#include "tuplesresultwidget.h"
#include "SqlDocument.h"
#include <QWidget>
#include <memory>
@ -25,7 +24,6 @@ class QTableView;
class QTabWidget;
class SqlSyntaxHighlighter;
class SqlCompletionProvider;
class ExplainRoot;
class QueryExplainModel;
class PgTypeContainer;
@ -36,9 +34,12 @@ class PgDatabaseCatalog;
class QueryTool : public ManagedPage {
Q_OBJECT
public:
QueryTool(IDatabaseWindow *context, std::shared_ptr<SqlDocument> document, QWidget *parent = nullptr);
QueryTool(IDatabaseWindow *context, QWidget *parent = nullptr);
~QueryTool() override;
void newdoc();
bool load(const QString &filename);
void explain(bool analyze);
bool CanClose(bool prompt_user);
@ -46,12 +47,12 @@ public:
void generateCode();
void exportDataToFilename(const QString &filename);
QString fileName() const { return m_document->fileName(); }
std::shared_ptr<SqlDocument> document() const { return m_document; }
QString fileName() const { return m_fileName; }
bool isChanged() const { return m_queryTextChanged; }
bool isNew() const { return m_new; }
void focusEditor();
QString title() const;
void updateTitle();
public slots:
@ -73,22 +74,27 @@ private:
using ResultTabContainer = std::vector<TuplesResultWidget*>;
IDatabaseWindow *m_context;
Ui::QueryTab *ui;
std::shared_ptr<SqlDocument> m_document;
SqlSyntaxHighlighter* highlighter;
ConnectionConfig m_config;
StopWatch m_stopwatch;
QueryParamListController *m_queryParamListController = nullptr;
bool m_new = true;
QString m_fileName; ///< use setFileName function to set
bool m_queryTextChanged = false;
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;
std::unique_ptr<QueryExplainModel> explainModel;
ResultTabContainer resultList;
void setFileName(const QString &filename);
bool continueWithoutSavingWarning();
bool saveSqlTo(const QString &filename);
QString promptUserForSaveSqlFilename();
void addLog(QString s);
QString getCommand() const;
@ -96,12 +102,15 @@ private:
void clearResult();
void markError(const Pgsql::ErrorDetails &details);
void updateTitle();
void setQueryTextChanged(bool queryTextChanged);
private slots:
void explain_ready(ExplainRoot::SPtr explain);
void query_ready(std::shared_ptr<Pgsql::Result>, qint64 elapsedms);
void queryTextChanged();
void connectionStateChanged();
void receiveNotice(Pgsql::ErrorDetails notice);

View file

@ -1,143 +0,0 @@
#include "SqlDocument.h"
#include "util/SqlSyntaxHighlighter.h"
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QStandardPaths>
#include <QStringConverter>
#include <QPlainTextDocumentLayout>
#include <QTextDocument>
#include <QTextStream>
SqlDocument::SqlDocument(QObject *parent)
: QObject(parent)
, m_document(new QTextDocument(this))
, m_highlighter(new SqlSyntaxHighlighter(m_document))
{
// QPlainTextEdit only accepts documents that use a QPlainTextDocumentLayout
m_document->setDocumentLayout(new QPlainTextDocumentLayout(m_document));
connect(m_document, &QTextDocument::modificationChanged, this, &SqlDocument::modifiedChanged);
setFileName(tr("new"));
}
SqlDocument::~SqlDocument() = default;
bool SqlDocument::isModified() const
{
return m_document->isModified();
}
void SqlDocument::setModified(bool modified)
{
m_document->setModified(modified);
}
void SqlDocument::clear()
{
m_document->clear();
setFileName(tr("new"));
m_new = true;
setModified(false);
}
bool SqlDocument::load(const QString &filename)
{
bool result = false;
QFile file(filename);
if (file.open(QIODevice::ReadOnly)) {
QByteArray ba = file.readAll();
auto toUtf16 = QStringDecoder(QStringDecoder::Utf8);
QString text = toUtf16(ba);
if (toUtf16.hasError()) {
file.reset();
QTextStream stream(&file);
text = stream.readAll();
}
m_document->setPlainText(text);
setModified(false);
setFileName(filename);
m_new = false;
result = true;
}
return result;
}
bool SqlDocument::save(QWidget *parent)
{
bool result;
if (m_fileName.isEmpty() || m_new) {
result = saveAs(parent);
}
else {
result = saveSqlTo(m_fileName);
}
return result;
}
bool SqlDocument::saveAs(QWidget *parent)
{
bool result = false;
QString filename = promptUserForSaveSqlFilename(parent);
if (!filename.isEmpty()) {
result = saveSqlTo(filename);
if (result) {
setFileName(filename);
m_new = false;
}
}
return result;
}
bool SqlDocument::saveCopyAs(QWidget *parent)
{
bool result = false;
QString filename = promptUserForSaveSqlFilename(parent);
if (!filename.isEmpty()) {
result = saveSqlTo(filename);
}
return result;
}
void SqlDocument::addEditor(QWidget *editor)
{
if (!m_editors.contains(editor))
m_editors.append(editor);
}
void SqlDocument::removeEditor(QWidget *editor)
{
m_editors.removeAll(editor);
}
void SqlDocument::setFileName(const QString &filename)
{
m_fileName = filename;
emit fileNameChanged();
}
bool SqlDocument::saveSqlTo(const QString &filename)
{
bool result = false;
QFile file(filename);
if (file.open(QIODevice::WriteOnly)) {
QTextStream stream(&file);
stream.setEncoding(QStringConverter::Utf8);
QString text = m_document->toPlainText();
stream << text;
stream.flush();
if (stream.status() == QTextStream::Ok) {
setModified(false);
result = true;
}
}
return result;
}
QString SqlDocument::promptUserForSaveSqlFilename(QWidget *parent)
{
QString home_dir = QStandardPaths::locate(QStandardPaths::HomeLocation, "", QStandardPaths::LocateDirectory);
return QFileDialog::getSaveFileName(parent, tr("Save query"), home_dir, tr("SQL file (*.sql)"));
}

View file

@ -1,60 +0,0 @@
#ifndef SQLDOCUMENT_H
#define SQLDOCUMENT_H
#include <QList>
#include <QObject>
#include <QString>
class QTextDocument;
class QWidget;
class SqlSyntaxHighlighter;
/** Owns the text of a SQL script together with its file state.
* Several query tools (editors) can show the same document, they all
* use the same QTextDocument.
*/
class SqlDocument : public QObject {
Q_OBJECT
public:
explicit SqlDocument(QObject *parent = nullptr);
~SqlDocument() override;
QTextDocument *textDocument() const { return m_document; }
SqlSyntaxHighlighter *highlighter() const { return m_highlighter; }
QString fileName() const { return m_fileName; }
bool isNew() const { return m_new; }
bool isModified() const;
void setModified(bool modified);
/// Empties the document and makes it a new unnamed document.
void clear();
bool load(const QString &filename);
/// Save the document under its current name, a file save dialog will be shown if this is a new document
bool save(QWidget *parent);
/// Saves the document under a new name and continue editing the document under this new name.
bool saveAs(QWidget *parent);
/// Save the document under a new name but continue editing the document under its old name.
bool saveCopyAs(QWidget *parent);
void addEditor(QWidget *editor);
void removeEditor(QWidget *editor);
const QList<QWidget*> &editors() const { return m_editors; }
signals:
void modifiedChanged(bool modified);
void fileNameChanged();
private:
QTextDocument *m_document;
SqlSyntaxHighlighter *m_highlighter;
QString m_fileName;
bool m_new = true;
QList<QWidget*> m_editors;
void setFileName(const QString &filename);
bool saveSqlTo(const QString &filename);
QString promptUserForSaveSqlFilename(QWidget *parent);
};
#endif // SQLDOCUMENT_H

View file

@ -1,27 +0,0 @@
#include "SqlDocumentRegistry.h"
#include "SqlDocument.h"
void SqlDocumentRegistry::add(const std::shared_ptr<SqlDocument> &document)
{
m_documents.push_back(document);
}
QList<std::shared_ptr<SqlDocument>> SqlDocumentRegistry::documents() const
{
QList<std::shared_ptr<SqlDocument>> result;
for (const auto &weak : m_documents) {
if (auto doc = weak.lock())
result.append(doc);
}
return result;
}
QList<std::shared_ptr<SqlDocument>> SqlDocumentRegistry::modifiedDocuments() const
{
QList<std::shared_ptr<SqlDocument>> result;
for (auto &doc : documents()) {
if (doc->isModified())
result.append(doc);
}
return result;
}

View file

@ -1,27 +0,0 @@
#ifndef SQLDOCUMENTREGISTRY_H
#define SQLDOCUMENTREGISTRY_H
#include <QList>
#include <memory>
#include <vector>
class SqlDocument;
/** Keeps track of all SQL documents that are still in use, without
* extending their lifetime. A document lives as long as at least one
* query tool holds a shared pointer to it.
*/
class SqlDocumentRegistry {
public:
void add(const std::shared_ptr<SqlDocument> &document);
/// All documents that are still alive.
QList<std::shared_ptr<SqlDocument>> documents() const;
/// All documents that are alive and have unsaved changes.
QList<std::shared_ptr<SqlDocument>> modifiedDocuments() const;
private:
std::vector<std::weak_ptr<SqlDocument>> m_documents;
};
#endif // SQLDOCUMENTREGISTRY_H

View file

@ -1,112 +0,0 @@
#include "UnsavedDocumentsDialog.h"
#include <QDialogButtonBox>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include <QLabel>
#include <algorithm>
UnsavedDocumentsDialog::UnsavedDocumentsDialog(const QList<std::shared_ptr<SqlDocument>> &docs, QWidget *parent)
: QDialog(parent)
, m_docs(docs)
, m_list(new QListWidget(this))
{
setWindowTitle(tr("Unsaved changes"));
auto layout = new QVBoxLayout(this);
layout->addWidget(new QLabel(tr("The following documents have unsaved changes. Select the documents to save or discard:"), this));
for (const auto &doc : m_docs) {
QStringList windows;
for (const QWidget *editor : doc->editors()) {
QString title = editor->window()->windowTitle();
if (!windows.contains(title))
windows.append(title);
}
auto item = new QListWidgetItem(QString("%1 (%2)").arg(doc->fileName(), windows.join(", ")), m_list);
item->setToolTip(doc->fileName());
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(Qt::Checked);
}
layout->addWidget(m_list);
auto buttons = new QDialogButtonBox(this);
auto save_button = buttons->addButton(tr("Save selected"), QDialogButtonBox::AcceptRole);
auto discard_button = buttons->addButton(tr("Discard selected"), QDialogButtonBox::DestructiveRole);
buttons->addButton(QDialogButtonBox::Cancel);
layout->addWidget(buttons);
connect(save_button, &QPushButton::clicked, this, [this]() {
m_choice = Choice::Save;
accept();
});
connect(discard_button, &QPushButton::clicked, this, [this]() {
m_choice = Choice::Discard;
accept();
});
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
QList<std::shared_ptr<SqlDocument>> UnsavedDocumentsDialog::checkedDocuments() const
{
QList<std::shared_ptr<SqlDocument>> result;
for (int i = 0; i < m_docs.size(); ++i) {
if (m_list->item(i)->checkState() == Qt::Checked)
result.append(m_docs[i]);
}
return result;
}
QList<std::shared_ptr<SqlDocument>> UnsavedDocumentsDialog::uncheckedDocuments() const
{
QList<std::shared_ptr<SqlDocument>> result;
for (int i = 0; i < m_docs.size(); ++i) {
if (m_list->item(i)->checkState() != Qt::Checked)
result.append(m_docs[i]);
}
return result;
}
QList<std::shared_ptr<SqlDocument>> documentsClosedWith(const QList<std::shared_ptr<SqlDocument>> &docs,
const std::function<bool(const QWidget*)> &isClosing)
{
QList<std::shared_ptr<SqlDocument>> result;
for (const auto &doc : docs) {
const auto &editors = doc->editors();
if (!doc->isModified() || editors.isEmpty())
continue;
bool all_closing = std::all_of(editors.begin(), editors.end(),
[&isClosing](const QWidget *editor) { return isClosing(editor); });
if (all_closing)
result.append(doc);
}
return result;
}
bool confirmClose(QWidget *parent, const QList<std::shared_ptr<SqlDocument>> &docs)
{
auto remaining = docs;
while (!remaining.isEmpty()) {
UnsavedDocumentsDialog dlg(remaining, parent);
if (dlg.exec() != QDialog::Accepted || dlg.choice() == UnsavedDocumentsDialog::Choice::Cancel)
return false;
bool save = dlg.choice() == UnsavedDocumentsDialog::Choice::Save;
for (const auto &doc : dlg.checkedDocuments()) {
if (save) {
if (!doc->save(parent))
return false;
}
else {
doc->setModified(false);
}
}
// Show the dialog again for the documents the user did not select.
remaining = dlg.uncheckedDocuments();
}
return true;
}

View file

@ -1,48 +0,0 @@
#ifndef UNSAVEDDOCUMENTSDIALOG_H
#define UNSAVEDDOCUMENTSDIALOG_H
#include "SqlDocument.h"
#include <QDialog>
#include <QList>
#include <functional>
#include <memory>
class QListWidget;
/** Lists modified documents with a checkbox per document. The user picks
* whether the checked documents should be saved or discarded.
*/
class UnsavedDocumentsDialog : public QDialog {
Q_OBJECT
public:
enum class Choice {
Cancel,
Save,
Discard
};
UnsavedDocumentsDialog(const QList<std::shared_ptr<SqlDocument>> &docs, QWidget *parent = nullptr);
Choice choice() const { return m_choice; }
QList<std::shared_ptr<SqlDocument>> checkedDocuments() const;
QList<std::shared_ptr<SqlDocument>> uncheckedDocuments() const;
private:
QList<std::shared_ptr<SqlDocument>> m_docs;
QListWidget *m_list;
Choice m_choice = Choice::Cancel;
};
/** Selects the documents that are closed when every editor for which
* isClosing returns true is closed. Only modified documents are returned.
*/
QList<std::shared_ptr<SqlDocument>> documentsClosedWith(const QList<std::shared_ptr<SqlDocument>> &docs,
const std::function<bool(const QWidget*)> &isClosing);
/** Asks the user what to do with the modified documents. Returns true when
* the close may continue, i.e. nothing was cancelled, no save failed and
* every listed document was checked.
*/
bool confirmClose(QWidget *parent, const QList<std::shared_ptr<SqlDocument>> &docs);
#endif // UNSAVEDDOCUMENTSDIALOG_H

View file

@ -21,6 +21,7 @@ TuplesResultWidget::TuplesResultWidget(QWidget *parent) :
m_grid = new DataGridWidget(this);
ui->verticalLayout->insertWidget(0, m_grid);
m_grid->view()->setFont(QFont("Source Sans Pro", 10));
m_grid->view()->setAlternatingRowColors(true);
m_grid->view()->setWordWrap(false);
m_grid->view()->verticalHeader()->setDefaultSectionSize(21);

View file

@ -31,12 +31,6 @@
</property>
<item>
<widget class="QWidget" name="widget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>4</number>

View file

@ -0,0 +1,61 @@
#include "SingleRecordModel.h"
#include "CustomDataRole.h"
SingleRecordModel::SingleRecordModel(QObject *parent)
: QAbstractTableModel(parent)
{
}
QVariant SingleRecordModel::headerData(int section, Qt::Orientation orientation, int role) const
{
return {};
}
int SingleRecordModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return elems.size();
}
int SingleRecordModel::columnCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return ColCount;
}
QVariant SingleRecordModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
return QVariant();
const int row = index.row();
if (role == Qt::DisplayRole)
{
switch (index.column())
{
case NameCol:
return elems[row].name;
case ValueCol:
return elems[row].value;
}
}
else if (role == CustomDataTypeRole)
{
switch (index.column())
{
case NameCol:
return Pgsql::text_oid;
case ValueCol:
return elems[row].oid;
}
}
else if (role == CustomDataMeaningRole)
{
}
return QVariant();
}

View file

@ -0,0 +1,39 @@
#pragma once
#include <QAbstractTableModel>
#include "Pgsql_oids.h"
class SingleRecordModel : public QAbstractTableModel
{
Q_OBJECT
public:
explicit SingleRecordModel(QObject *parent = nullptr);
// Header:
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
// Basic functionality:
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
private:
enum Columns {
NameCol,
ValueCol,
ColCount
};
class Elem {
public:
QString name;
QString value;
Oid oid;
};
using Elems = std::vector<Elem>;
Elems elems;
};

View file

@ -0,0 +1,14 @@
#include "SingleRecordWidget.h"
#include "ui_SingleRecordWidget.h"
SingleRecordWidget::SingleRecordWidget(QWidget *parent) :
QWidget(parent),
ui(new Ui::SingleRecordWidget)
{
ui->setupUi(this);
}
SingleRecordWidget::~SingleRecordWidget()
{
delete ui;
}

View file

@ -0,0 +1,20 @@
#pragma once
#include <QWidget>
namespace Ui {
class SingleRecordWidget;
}
class SingleRecordWidget : public QWidget
{
Q_OBJECT
public:
explicit SingleRecordWidget(QWidget *parent = nullptr);
~SingleRecordWidget();
private:
Ui::SingleRecordWidget *ui;
};

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SingleRecordWidget</class>
<widget class="QWidget" name="SingleRecordWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>500</width>
<height>679</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="PgLabTableView" name="properties"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>PgLabTableView</class>
<extends>QTableView</extends>
<header>util/PgLabTableView.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View file

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

View file

@ -1,430 +0,0 @@
#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

@ -1,69 +0,0 @@
#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

@ -77,7 +77,7 @@ void PgSequenceContainer::loadAll(Pgsql::Connection &conn)
q += ", start_value, increment_by, max_value, \n"
" min_value, cache_value, is_cycled";
}
q += "\nFROM " + std::string(fqn.data(), static_cast<size_t>(fqn.size()));
q += "\nFROM " + std::string(fqn.data(), static_cast<size_t>(fqn.count()));
auto && res = conn.query(q.c_str());
if (res.rows() == 1) {
Pgsql::Row row(res, 0);

View file

@ -267,9 +267,13 @@ KeyStrengthener PasswordManager::createKeyStrengthener()
const std::string algo = "Scrypt";
auto pwh_fam = Botan::PasswordHashFamily::create(algo);
return KeyStrengthener(
#ifdef _WIN32
pwh_fam->tune_params(key_size, 2000, 130),
salt,
key_size
);
#else
pwh_fam->tune(key_size, std::chrono::seconds(2), 130),
#endif
salt,
key_size
);
}

View file

@ -1,9 +0,0 @@
---
features:
- |
Data grids in the table editor and in query results now have a record
panel next to the grid. It shows the column name, type and value of the
current row, long text, varchar, json, jsonb and xml values get an
expandable multi-line box. Values are editable exactly when the grid cell
is editable. The panel can be hidden with the :menuselection:`Record`
button.

View file

@ -1,7 +0,0 @@
---
fixes:
- |
Fixed SSL mode selection and saving in the connection configuration dialog.
The field now offers the supported modes, including the corrected verify-ca
and verify-full values, and a Default option to leave the mode unset.
Existing verify_ca and verify_full values are corrected when saved.

View file

@ -1,5 +0,0 @@
---
fixes:
- |
Keep record panel fields compact at the top instead of spreading them
over the available height when a record has few fields.

View file

@ -1,6 +0,0 @@
---
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

@ -1,9 +0,0 @@
---
features:
- |
Closing a window or quitting the application now shows a single "Unsaved changes" dialog listing all modified SQL documents. Select the documents to save or discard; after the action has completed the dialog is shown again for the remaining documents, until all are handled or you cancel.
- |
A SQL document can be opened in another window with its own connection via the new "Open in other window / Duplicate in window..." action in the File menu. Both windows share the same text, and the document is only prompted for when its last editor closes.
fixes:
- |
Quitting the application from the connection manager no longer discards unsaved SQL changes silently.

View file

@ -2,7 +2,6 @@
#include <gmock/gmock-matchers.h>
#include "Pgsql_Value.h"
#include "PrintTo_Qt.h"
#include <QTimeZone>
#include <iterator>
#include <set>
#include <vector>
@ -30,7 +29,7 @@ TEST(Pgsql_Value, test_QDateTime_2)
Pgsql::Value v("2017-12-01 09:38:17.339817+02", timestamptz_oid);
QDateTime dt = (QDateTime)v;
QDateTime exp(QDate(2017, 12, 1), QTime(9, 38, 17, 340),
QTimeZone::fromSecondsAheadOfUtc(2 * 3600));
Qt::OffsetFromUTC, 2*3600);
ASSERT_EQ(dt, exp);
}

View file

@ -3,21 +3,13 @@ add_executable(IntegrationTests
tst_Smoke.cpp
tst_CatalogLoad.cpp
tst_DatabaseSizes.cpp
tst_DataGridMapping.cpp
tst_DataGridUpdate.cpp
tst_CrudModelBehavior.cpp
)
target_link_libraries(IntegrationTests PRIVATE
pgintegration
pglabapp
pglablib
GTest::gtest
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Sql
Qt6::Concurrent
)
target_compile_definitions(IntegrationTests PRIVATE

View file

@ -1,96 +0,0 @@
#pragma once
#include <gtest/gtest.h>
#include "DbTest.h"
#include "ConnectionConfig.h"
#include "OpenDatabase.h"
#include "crud/CrudModel.h"
#include "crud/DataGridWidget.h"
#include "Pgsql_Connection.h"
#include <QCoreApplication>
#include <QDeadlineTimer>
#include <QSortFilterProxyModel>
#include "catalog/PgClassContainer.h"
#include "catalog/PgDatabaseCatalog.h"
#include <QUrl>
#include <memory>
#include <optional>
#include <string>
#include <vector>
class DataGridTest : public pgtest::DbTest {
protected:
std::shared_ptr<OpenDatabase> openDb()
{
QUrl url(dbUrl());
ConnectionConfig cfg;
cfg.setHost(url.host());
cfg.setPort(static_cast<unsigned short>(url.port(5432)));
cfg.setUser(url.userName());
cfg.setPassword(url.password());
cfg.setDbname(url.path().mid(1));
return OpenDatabase::createOpenDatabase(cfg);
}
/// Runs the query and shows its result in a grid
void showQuery(const char *sql)
{
db = openDb();
auto result = std::make_shared<Pgsql::Result>(conn().query(sql));
model = std::make_unique<CrudModel>();
widget = std::make_unique<DataGridWidget>();
widget->setSourceModel(model.get());
model->setResult(db, result);
}
/// Shows a whole table (CRUD mode) and waits for the asynchronous load to finish
bool showTable(const QString &name)
{
db = openDb();
auto table = db->catalog()->classes()->findIf([&name](const PgClass &c)
{ return c.fullyQualifiedQuotedObjectName().endsWith("." + name); });
if (!table)
return false;
model = std::make_unique<CrudModel>();
widget = std::make_unique<DataGridWidget>();
widget->setSourceModel(model.get());
bool reset = false;
QObject::connect(model.get(), &QAbstractItemModel::modelReset, model.get(), [&reset] { reset = true; });
model->setConfig(db, *table);
QDeadlineTimer deadline(10000);
while (!reset && !deadline.hasExpired())
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
return reset;
}
/// Editable flag of every result column, as seen through the widget's proxy
std::vector<bool> editableColumns()
{
auto proxy = widget->proxy();
std::vector<bool> r;
for (int c = 1; c < proxy->columnCount(); ++c)
r.push_back(proxy->flags(proxy->index(0, c)) & Qt::ItemIsEditable);
return r;
}
QString columnName(int resultCol)
{
return widget->proxy()->headerData(resultCol + 1, Qt::Horizontal).toString().section('\n', 0, 0);
}
/// Reads a single cell with the test's own connection
std::optional<std::string> dbValue(const char *sql)
{
auto res = conn().query(sql);
auto v = res.get(0, 0);
if (v.null())
return std::nullopt;
return std::string(v.c_str());
}
std::shared_ptr<OpenDatabase> db;
std::unique_ptr<CrudModel> model;
std::unique_ptr<DataGridWidget> widget;
};

View file

@ -4,57 +4,3 @@ CREATE TABLE fixture_item (
);
INSERT INTO fixture_item (id, name) VALUES (1, 'one'), (2, 'two');
CREATE TABLE grid_parent (
id integer PRIMARY KEY,
name text NOT NULL
);
INSERT INTO grid_parent (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma');
CREATE TABLE grid_child (
id integer PRIMARY KEY,
parent_id integer,
note text
);
INSERT INTO grid_child (id, parent_id, note) VALUES (10, 1, 'c1'), (11, 2, 'c2'), (12, 3, 'c3');
CREATE TABLE grid_composite (
a integer,
b integer,
val text,
PRIMARY KEY (a, b)
);
INSERT INTO grid_composite (a, b, val) VALUES (1, 1, 'x'), (1, 2, 'y'), (2, 1, 'z');
CREATE TABLE grid_nopk (
x integer,
y text
);
INSERT INTO grid_nopk (x, y) VALUES (1, 'a'), (2, 'b');
CREATE TABLE crud_item (
id serial PRIMARY KEY,
name text NOT NULL,
qty integer,
flag boolean,
born date,
tag text DEFAULT 'dflt'
);
INSERT INTO crud_item (name, qty, flag, born) VALUES
('a', 1, true, '2020-01-02'),
('b', 2, false, NULL),
('c', NULL, NULL, '2021-03-04'),
('d', 4, true, NULL),
('e', 5, false, NULL);
CREATE TABLE crud_empty (
id serial PRIMARY KEY,
v text
);
CREATE TABLE crud_gen (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
a integer,
b integer GENERATED ALWAYS AS (a * 2) STORED
);
INSERT INTO crud_gen (a) VALUES (1), (2);

View file

@ -1,15 +1,9 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QtGlobal>
#include "PgTestEnvironment.h"
int main(int argc, char *argv[])
{
if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM"))
qputenv("QT_QPA_PLATFORM", "offscreen");
QApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
::testing::AddGlobalTestEnvironment(new pgtest::PgEnv(PGTEST_FIXTURE_DIR));
return RUN_ALL_TESTS();

View file

@ -1,656 +0,0 @@
// Characterization tests for CrudModel, they lock in the behavior of the model
// so it can be refactored safely.
#include "DataGridTest.h"
#include "CustomDataRole.h"
#include "Pgsql_oids.h"
#include <QApplication>
#include <QBrush>
#include <QDate>
#include <QTimer>
namespace {
/// Failed saves show a modal message box, this closes it so the test can continue
void autoCloseModalDialogs(int triesLeft = 100)
{
if (auto w = QApplication::activeModalWidget())
w->close();
else if (triesLeft > 0)
QTimer::singleShot(20, [triesLeft] { autoCloseModalDialogs(triesLeft - 1); });
}
/// Records the values of CrudModel::pendingChangesChanged
struct PendingSpy {
explicit PendingSpy(CrudModel *m)
{
QObject::connect(m, &CrudModel::pendingChangesChanged, m, [this](bool b) { values.push_back(b); });
}
std::vector<bool> values;
};
/// Counts the emissions of a signal
struct CountSpy {
template <typename Sender, typename Func>
CountSpy(Sender *sender, Func signal)
{
QObject::connect(sender, signal, sender, [this] { ++n; });
}
int count() const { return n; }
int n = 0;
};
// crud_item columns
constexpr int ColId = 0;
constexpr int ColName = 1;
constexpr int ColQty = 2;
constexpr int ColFlag = 3;
constexpr int ColBorn = 4;
constexpr int ColTag = 5;
class CrudModelBehaviorTest : public DataGridTest {
protected:
QModelIndex idx(int row, int resultCol) const { return model->index(row, resultCol + 1); }
QVariant cell(int row, int resultCol, int role = Qt::DisplayRole) const
{
return model->data(idx(row, resultCol), role);
}
bool edit(int row, int resultCol, const QString &value)
{
return model->setData(idx(row, resultCol), value, Qt::EditRole);
}
/// Saves while auto closing any error dialog
bool save()
{
QTimer::singleShot(0, [] { autoCloseModalDialogs(); });
return model->saveChanges();
}
/// Row of crud_item with the given name, -1 if not shown
int rowOfName(const QString &name) const
{
for (int r = 0; r < model->rowCount(); ++r)
if (cell(r, ColName, Qt::EditRole).toString() == name)
return r;
return -1;
}
QStringList names() const
{
QStringList l;
for (int r = 0; r < model->rowCount(); ++r)
l << cell(r, ColName, Qt::EditRole).toString();
return l;
}
void reload()
{
bool reset = false;
QObject::connect(model.get(), &QAbstractItemModel::modelReset, model.get(), [&reset] { reset = true; });
model->loadData();
QDeadlineTimer deadline(10000);
while (!reset && !deadline.hasExpired())
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
ASSERT_TRUE(reset);
}
};
} // namespace
// ---- loading, headers, row number column
TEST_F(CrudModelBehaviorTest, WholeTableLoadsRowsPlusEmptyNewRow)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_EQ(model->rowCount(), 6);
EXPECT_EQ(model->columnCount(), 7);
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", ""}));
for (int c = 0; c <= ColTag; ++c)
EXPECT_FALSE(cell(5, c).isValid()) << c;
}
TEST_F(CrudModelBehaviorTest, EmptyTableHasOnlyTheNewRow)
{
ASSERT_TRUE(showTable("crud_empty"));
EXPECT_EQ(model->rowCount(), 1);
}
TEST_F(CrudModelBehaviorTest, QueryResultHasNoNewRow)
{
showQuery("SELECT id, name FROM grid_parent");
EXPECT_EQ(model->rowCount(), 3);
}
TEST_F(CrudModelBehaviorTest, HeaderShowsNameAndType)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_EQ(model->headerData(0, Qt::Horizontal).toString(), "#");
for (int c = 0; c <= ColTag; ++c)
{
auto text = model->headerData(c + 1, Qt::Horizontal, Qt::DisplayRole).toString();
EXPECT_EQ(text.count('\n'), 1) << c;
EXPECT_FALSE(text.section('\n', 1).isEmpty()) << c;
}
EXPECT_EQ(columnName(ColId), "id");
EXPECT_EQ(columnName(ColName), "name");
EXPECT_EQ(columnName(ColTag), "tag");
EXPECT_FALSE(model->headerData(1, Qt::Horizontal, Qt::EditRole).isValid());
}
TEST_F(CrudModelBehaviorTest, RowNumberColumnData)
{
ASSERT_TRUE(showTable("crud_item"));
for (int r = 0; r < model->rowCount(); ++r)
{
auto i = model->index(r, 0);
EXPECT_EQ(model->data(i, Qt::DisplayRole).toInt(), r + 1);
EXPECT_EQ(model->data(i, CustomSortRole).toInt(), r + 1);
EXPECT_EQ(model->data(i, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::int4_oid));
EXPECT_FALSE(model->data(i, Qt::EditRole).isValid());
EXPECT_FALSE(model->data(i, Qt::ForegroundRole).isValid());
}
EXPECT_FALSE(model->setData(model->index(0, 0), "5", Qt::EditRole));
EXPECT_FALSE(model->hasPendingChanges());
}
// ---- data roles
TEST_F(CrudModelBehaviorTest, DisplayAndEditRoles)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_EQ(cell(0, ColName).toString(), "a");
EXPECT_EQ(cell(0, ColQty).toString(), "1");
EXPECT_EQ(cell(0, ColQty, Qt::EditRole).toString(), "1");
EXPECT_EQ(cell(0, ColBorn).toString(), "2020-01-02");
EXPECT_EQ(cell(0, ColTag).toString(), "dflt");
// NULL
EXPECT_FALSE(cell(1, ColBorn).isValid());
EXPECT_FALSE(cell(1, ColBorn, Qt::EditRole).isValid());
// Roles that are not handled
EXPECT_FALSE(cell(0, ColName, Qt::ToolTipRole).isValid());
EXPECT_FALSE(cell(0, ColName, Qt::ForegroundRole).isValid());
}
TEST_F(CrudModelBehaviorTest, BoolDisplaysAsBool)
{
ASSERT_TRUE(showTable("crud_item"));
auto t = cell(0, ColFlag);
EXPECT_EQ(t.typeId(), QMetaType::Bool);
EXPECT_TRUE(t.toBool());
auto f = cell(1, ColFlag);
EXPECT_EQ(f.typeId(), QMetaType::Bool);
EXPECT_FALSE(f.toBool());
EXPECT_FALSE(cell(2, ColFlag).isValid());
EXPECT_EQ(cell(0, ColFlag, Qt::EditRole).toString(), "t");
}
TEST_F(CrudModelBehaviorTest, DisplayTruncatesButEditDoesNot)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(0, ColName, QString(300, 'x')));
EXPECT_EQ(cell(0, ColName, Qt::DisplayRole).toString().size(), 256);
EXPECT_EQ(cell(0, ColName, Qt::EditRole).toString().size(), 300);
}
TEST_F(CrudModelBehaviorTest, SortRoleGivesTypedValues)
{
ASSERT_TRUE(showTable("crud_item"));
auto i = cell(0, ColQty, CustomSortRole);
EXPECT_EQ(i.typeId(), QMetaType::Int);
EXPECT_EQ(i.toInt(), 1);
EXPECT_EQ(cell(0, ColBorn, CustomSortRole).toDate(), QDate(2020, 1, 2));
EXPECT_EQ(cell(0, ColBorn, CustomSortRole).typeId(), QMetaType::QDate);
auto b = cell(0, ColFlag, CustomSortRole);
EXPECT_EQ(b.typeId(), QMetaType::Bool);
EXPECT_TRUE(b.toBool());
EXPECT_EQ(cell(0, ColName, CustomSortRole).toString(), "a");
EXPECT_FALSE(cell(1, ColBorn, CustomSortRole).isValid());
EXPECT_FALSE(cell(2, ColQty, CustomSortRole).isValid());
}
TEST_F(CrudModelBehaviorTest, DataTypeRole)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_EQ(cell(0, ColId, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::int4_oid));
EXPECT_EQ(cell(0, ColName, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::text_oid));
EXPECT_EQ(cell(0, ColFlag, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::bool_oid));
EXPECT_EQ(cell(0, ColBorn, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::date_oid));
// also for the new row
EXPECT_EQ(cell(5, ColQty, CustomDataTypeRole).toUInt(), static_cast<uint>(Pgsql::int4_oid));
}
TEST_F(CrudModelBehaviorTest, ReadOnlyColumnsAreGrayInQueryMode)
{
showQuery("SELECT id, name || 'x' AS e FROM grid_parent");
EXPECT_FALSE(cell(0, 0, Qt::ForegroundRole).isValid());
auto brush = cell(0, 1, Qt::ForegroundRole);
ASSERT_TRUE(brush.isValid());
EXPECT_EQ(brush.value<QBrush>().color(), QColor(Qt::gray));
}
// ---- flags
TEST_F(CrudModelBehaviorTest, FlagsOfGeneratedAndIdentityColumns)
{
ASSERT_TRUE(showTable("crud_gen"));
EXPECT_EQ(editableColumns(), (std::vector<bool>{false, true, false}));
auto f = model->flags(model->index(0, 0));
EXPECT_TRUE(f & Qt::ItemIsSelectable);
EXPECT_TRUE(f & Qt::ItemIsEnabled);
EXPECT_FALSE(f & Qt::ItemIsEditable);
}
TEST_F(CrudModelBehaviorTest, FlagsOfWholeTable)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_EQ(editableColumns(), (std::vector<bool>(6, true)));
}
TEST_F(CrudModelBehaviorTest, ExpressionColumnIsNotEditable)
{
showQuery("SELECT id, name || 'x' AS e FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, false}));
}
TEST_F(CrudModelBehaviorTest, TableWithoutPkIsNotEditable)
{
ASSERT_TRUE(showTable("grid_nopk"));
EXPECT_EQ(editableColumns(), (std::vector<bool>{false, false}));
}
// ---- setData
TEST_F(CrudModelBehaviorTest, SetDataRejectsOtherRoles)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_FALSE(model->setData(idx(0, ColName), "x", Qt::DisplayRole));
EXPECT_FALSE(model->hasPendingChanges());
}
TEST_F(CrudModelBehaviorTest, SetDataShowsPendingValueAndEmitsDataChanged)
{
ASSERT_TRUE(showTable("crud_item"));
CountSpy spy(model.get(), &QAbstractItemModel::dataChanged);
ASSERT_TRUE(edit(0, ColName, "changed"));
EXPECT_EQ(spy.count(), 1);
EXPECT_TRUE(model->hasPendingChanges());
EXPECT_EQ(cell(0, ColName).toString(), "changed");
EXPECT_EQ(cell(1, ColName).toString(), "b");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "a");
}
TEST_F(CrudModelBehaviorTest, EmptyStringBecomesNullAndQuotesBecomeEmptyString)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(0, ColTag, ""));
ASSERT_TRUE(edit(1, ColTag, "''"));
EXPECT_FALSE(cell(0, ColTag).isValid());
EXPECT_TRUE(cell(1, ColTag).isValid());
EXPECT_EQ(cell(1, ColTag).toString(), "");
ASSERT_TRUE(save());
EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'a'"), std::nullopt);
EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'b'"), "");
EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'c'"), "dflt");
}
TEST_F(CrudModelBehaviorTest, RepeatedEditsOfSameCellKeepLastValue)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(0, ColName, "one"));
ASSERT_TRUE(edit(0, ColName, "two"));
ASSERT_TRUE(edit(1, ColName, "three"));
EXPECT_EQ(spy.values, (std::vector<bool>{true}));
ASSERT_TRUE(save());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false}));
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "two");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "three");
}
// ---- saving updates
TEST_F(CrudModelBehaviorTest, SavedUpdateIsShownAfterwards)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(2, ColQty, "42"));
ASSERT_TRUE(save());
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(cell(2, ColQty).toString(), "42");
EXPECT_EQ(cell(2, ColName).toString(), "c");
EXPECT_EQ(cell(2, ColBorn).toString(), "2021-03-04");
EXPECT_EQ(model->rowCount(), 6);
EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'c'"), "42");
}
TEST_F(CrudModelBehaviorTest, SaveWithoutChangesIsOk)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
EXPECT_TRUE(model->saveChanges());
EXPECT_TRUE(spy.values.empty());
}
TEST_F(CrudModelBehaviorTest, RevertThenEditAgain)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(0, ColName, "discarded"));
model->revertChanges();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(cell(0, ColName).toString(), "a");
model->revertChanges(); // nothing to revert, no signal
ASSERT_TRUE(edit(0, ColName, "kept"));
EXPECT_EQ(cell(0, ColName).toString(), "kept");
ASSERT_TRUE(save());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false, true, false}));
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "kept");
}
TEST_F(CrudModelBehaviorTest, RevertDoesNotRemoveSavedValues)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(0, ColName, "saved"));
ASSERT_TRUE(save());
ASSERT_TRUE(edit(0, ColName, "unsaved"));
model->revertChanges();
EXPECT_EQ(cell(0, ColName).toString(), "saved");
}
TEST_F(CrudModelBehaviorTest, PartialFailureKeepsOnlyFailingRowPending)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(0, ColName, "ok"));
ASSERT_TRUE(edit(1, ColName, "")); // NULL violates NOT NULL
ASSERT_TRUE(edit(2, ColName, "never saved"));
EXPECT_FALSE(save());
EXPECT_TRUE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{true}));
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "ok");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "b");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 3"), "c");
EXPECT_EQ(cell(0, ColName).toString(), "ok");
EXPECT_FALSE(cell(1, ColName).isValid()); // still shows the pending value
// Fixing the failing row lets the rest be saved
ASSERT_TRUE(edit(1, ColName, "fixed"));
ASSERT_TRUE(save());
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false}));
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "fixed");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 3"), "never saved");
}
TEST_F(CrudModelBehaviorTest, UpdateOfRowChangedElsewhereFails)
{
ASSERT_TRUE(showTable("crud_item"));
conn().query("DELETE FROM crud_item WHERE id = 1");
ASSERT_TRUE(edit(0, ColName, "gone"));
EXPECT_FALSE(save());
EXPECT_TRUE(model->hasPendingChanges());
}
// ---- reload
TEST_F(CrudModelBehaviorTest, ReloadClearsPendingChangesAndShowsDbState)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(0, ColName, "pending"));
conn().query("INSERT INTO crud_item (name) VALUES ('f')");
PendingSpy spy(model.get());
reload();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{false}));
EXPECT_EQ(model->rowCount(), 7);
EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "f", ""}));
}
TEST_F(CrudModelBehaviorTest, ReloadWithoutPendingChangesEmitsNoSignal)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
reload();
EXPECT_TRUE(spy.values.empty());
EXPECT_EQ(model->rowCount(), 6);
}
// ---- insert
TEST_F(CrudModelBehaviorTest, InsertNewRow)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(5, ColName, "new"));
ASSERT_TRUE(edit(5, ColQty, "7"));
EXPECT_EQ(spy.values, (std::vector<bool>{true}));
ASSERT_TRUE(save());
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false}));
EXPECT_EQ(model->rowCount(), 7);
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "6");
EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'new'"), "7");
// RETURNING * brings the defaults into the model
EXPECT_EQ(cell(5, ColName).toString(), "new");
EXPECT_EQ(cell(5, ColQty).toString(), "7");
EXPECT_EQ(cell(5, ColTag).toString(), "dflt");
EXPECT_EQ(cell(5, ColId).toString(), *dbValue("SELECT id FROM crud_item WHERE name = 'new'"));
EXPECT_FALSE(cell(5, ColBorn).isValid());
// and a fresh empty row is appended
EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "new", ""}));
EXPECT_FALSE(cell(6, ColId).isValid());
}
TEST_F(CrudModelBehaviorTest, InsertTwoRowsInARow)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(5, ColName, "n1"));
ASSERT_TRUE(save());
ASSERT_TRUE(edit(6, ColName, "n2"));
ASSERT_TRUE(save());
EXPECT_EQ(model->rowCount(), 8);
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "7");
EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "n1", "n2", ""}));
}
TEST_F(CrudModelBehaviorTest, InsertIntoEmptyTable)
{
ASSERT_TRUE(showTable("crud_empty"));
ASSERT_TRUE(edit(0, 1, "first"));
ASSERT_TRUE(save());
EXPECT_EQ(model->rowCount(), 2);
EXPECT_EQ(dbValue("SELECT v FROM crud_empty"), "first");
EXPECT_EQ(cell(0, 0).toString(), "1");
}
TEST_F(CrudModelBehaviorTest, InsertWithExplicitNull)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(5, ColName, "nulltag"));
ASSERT_TRUE(edit(5, ColTag, ""));
ASSERT_TRUE(save());
EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'nulltag'"), std::nullopt);
EXPECT_FALSE(cell(5, ColTag).isValid());
}
TEST_F(CrudModelBehaviorTest, FailedInsertKeepsRowPending)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(5, ColQty, "9")); // name is NOT NULL and missing
EXPECT_FALSE(save());
EXPECT_TRUE(model->hasPendingChanges());
EXPECT_EQ(model->rowCount(), 6);
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5");
EXPECT_EQ(cell(5, ColQty).toString(), "9");
ASSERT_TRUE(edit(5, ColName, "now valid"));
ASSERT_TRUE(save());
EXPECT_EQ(model->rowCount(), 7);
EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'now valid'"), "9");
}
TEST_F(CrudModelBehaviorTest, EditingOnlyThenRevertingNewRowDoesNotInsert)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(edit(5, ColName, "temp"));
model->revertChanges();
EXPECT_FALSE(cell(5, ColName).isValid());
EXPECT_EQ(model->rowCount(), 6);
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5");
}
// ---- delete
TEST_F(CrudModelBehaviorTest, RemoveSingleRow)
{
ASSERT_TRUE(showTable("crud_item"));
CountSpy spy(model.get(), &QAbstractItemModel::rowsRemoved);
auto [ok, msg] = model->removeRows({IntegerRange<int>(1, 1)});
EXPECT_TRUE(ok) << msg.toStdString();
EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(names(), (QStringList{"a", "c", "d", "e", ""}));
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "4");
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item WHERE name = 'b'"), "0");
EXPECT_EQ(model->data(model->index(4, 0), Qt::DisplayRole).toInt(), 5);
}
TEST_F(CrudModelBehaviorTest, RemoveContiguousRange)
{
ASSERT_TRUE(showTable("crud_item"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(1, 3)});
EXPECT_TRUE(ok) << msg.toStdString();
EXPECT_EQ(names(), (QStringList{"a", "e", ""}));
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "2");
}
TEST_F(CrudModelBehaviorTest, RemoveNonContiguousRanges)
{
ASSERT_TRUE(showTable("crud_item"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(0, 1), IntegerRange<int>(2, 2)});
EXPECT_TRUE(ok) << msg.toStdString();
EXPECT_EQ(names(), (QStringList{"b", "e", ""}));
EXPECT_EQ(dbValue("SELECT string_agg(name, ',' ORDER BY name) FROM crud_item"), "b,e");
}
TEST_F(CrudModelBehaviorTest, RemoveRowsOverloadForViews)
{
ASSERT_TRUE(showTable("crud_item"));
EXPECT_TRUE(model->removeRows(0, 2));
EXPECT_EQ(names(), (QStringList{"c", "d", "e", ""}));
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "3");
}
TEST_F(CrudModelBehaviorTest, RemoveNothingIsOk)
{
ASSERT_TRUE(showTable("crud_item"));
auto [ok, msg] = model->removeRows({});
EXPECT_TRUE(ok);
EXPECT_EQ(model->rowCount(), 6);
}
TEST_F(CrudModelBehaviorTest, RemoveDropsPendingEditsOfRemovedRows)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(1, ColName, "edited then removed"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(1, 1)});
EXPECT_TRUE(ok);
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false}));
}
TEST_F(CrudModelBehaviorTest, PendingEditsOfOtherRowsSurviveRemoval)
{
ASSERT_TRUE(showTable("crud_item"));
PendingSpy spy(model.get());
ASSERT_TRUE(edit(3, ColName, "d edited"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(0, 3)});
EXPECT_TRUE(ok);
EXPECT_TRUE(model->hasPendingChanges());
EXPECT_EQ(spy.values, (std::vector<bool>{true}));
EXPECT_EQ(names(), (QStringList{"d edited", "e", ""}));
ASSERT_TRUE(save());
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 4"), "d edited");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 5"), "e");
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "2");
}
TEST_F(CrudModelBehaviorTest, EditAfterRemovalUpdatesTheRightRow)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(std::get<0>(model->removeRows({IntegerRange<int>(0, 3)})));
ASSERT_TRUE(edit(1, ColName, "e edited"));
ASSERT_TRUE(save());
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 5"), "e edited");
EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 4"), "d");
}
TEST_F(CrudModelBehaviorTest, InsertAfterRemoval)
{
ASSERT_TRUE(showTable("crud_item"));
ASSERT_TRUE(std::get<0>(model->removeRows({IntegerRange<int>(0, 1)})));
ASSERT_TRUE(edit(model->rowCount() - 1, ColName, "after delete"));
ASSERT_TRUE(save());
EXPECT_EQ(names(), (QStringList{"b", "c", "d", "e", "after delete", ""}));
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5");
}
TEST_F(CrudModelBehaviorTest, RemoveOutOfRangeFails)
{
ASSERT_TRUE(showTable("crud_item"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(5, 5)});
EXPECT_FALSE(ok);
EXPECT_FALSE(msg.isEmpty());
EXPECT_EQ(model->rowCount(), 6);
EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5");
}
TEST_F(CrudModelBehaviorTest, RemoveFromQueryResultFails)
{
showQuery("SELECT id, name FROM grid_parent");
auto [ok, msg] = model->removeRows({IntegerRange<int>(0, 1)});
EXPECT_FALSE(ok);
EXPECT_FALSE(msg.isEmpty());
EXPECT_EQ(model->rowCount(), 3);
EXPECT_EQ(dbValue("SELECT count(*) FROM grid_parent"), "3");
}
TEST_F(CrudModelBehaviorTest, RemoveFromTableWithoutPkFails)
{
ASSERT_TRUE(showTable("grid_nopk"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(0, 1)});
EXPECT_FALSE(ok);
EXPECT_FALSE(msg.isEmpty());
EXPECT_EQ(model->rowCount(), 3);
EXPECT_EQ(dbValue("SELECT count(*) FROM grid_nopk"), "2");
}
TEST_F(CrudModelBehaviorTest, RemoveRowReferencedByForeignKeyFails)
{
conn().query("ALTER TABLE grid_child ADD FOREIGN KEY (parent_id) REFERENCES grid_parent (id)");
ASSERT_TRUE(showTable("grid_parent"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(0, 1)});
EXPECT_FALSE(ok);
EXPECT_EQ(model->rowCount(), 4);
EXPECT_EQ(dbValue("SELECT count(*) FROM grid_parent"), "3");
}
TEST_F(CrudModelBehaviorTest, RemoveFromCompositePkTable)
{
ASSERT_TRUE(showTable("grid_composite"));
auto [ok, msg] = model->removeRows({IntegerRange<int>(1, 1)});
EXPECT_TRUE(ok) << msg.toStdString();
EXPECT_EQ(dbValue("SELECT count(*) FROM grid_composite"), "2");
EXPECT_EQ(dbValue("SELECT count(*) FROM grid_composite WHERE a = 1 AND b = 2"), "0");
}

View file

@ -1,112 +0,0 @@
#include <gtest/gtest.h>
#include "catalog/PgClassContainer.h"
#include "catalog/PgDatabaseCatalog.h"
#include <QCoreApplication>
#include <QDeadlineTimer>
#include <QSortFilterProxyModel>
#include <QEventLoop>
#include "DataGridTest.h"
TEST_F(DataGridTest, SingleTableLoadsRows)
{
showQuery("SELECT id, name FROM grid_parent ORDER BY id");
EXPECT_EQ(model->rowCount(), 3);
ASSERT_NE(widget->proxy(), nullptr);
EXPECT_EQ(widget->proxy()->rowCount(), 3);
EXPECT_FALSE(model->hasPendingChanges());
}
TEST_F(DataGridTest, RowNumberColumnIsNeverEditable)
{
showQuery("SELECT id, name FROM grid_parent");
EXPECT_FALSE(widget->proxy()->flags(widget->proxy()->index(0, 0)) & Qt::ItemIsEditable);
}
TEST_F(DataGridTest, SingleTableAllColumnsEditable)
{
showQuery("SELECT id, name FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true}));
EXPECT_EQ(columnName(0), "id");
EXPECT_EQ(columnName(1), "name");
}
TEST_F(DataGridTest, SingleTableSubsetWithPkIsEditable)
{
showQuery("SELECT name, id FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true}));
EXPECT_EQ(columnName(0), "name");
}
TEST_F(DataGridTest, JoinOfTwoTablesWithPksIsEditable)
{
showQuery("SELECT p.id, p.name, c.id AS child_id, c.note FROM grid_parent p JOIN grid_child c ON c.parent_id = p.id");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true, true, true}));
EXPECT_EQ(columnName(2), "child_id");
}
TEST_F(DataGridTest, JoinWithoutOnePkOnlyOtherTableEditable)
{
showQuery("SELECT p.id, p.name, c.note FROM grid_parent p JOIN grid_child c ON c.parent_id = p.id");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true, false}));
}
TEST_F(DataGridTest, AliasedColumnStaysEditable)
{
showQuery("SELECT id, name AS title FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true}));
EXPECT_EQ(columnName(1), "title");
}
TEST_F(DataGridTest, ExpressionColumnIsReadOnly)
{
showQuery("SELECT id, name, upper(name) AS shout, id + 1 AS next_id FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true, false, false}));
}
TEST_F(DataGridTest, TableWithoutPkIsReadOnly)
{
showQuery("SELECT x, y FROM grid_nopk");
EXPECT_EQ(editableColumns(), (std::vector<bool>{false, false}));
}
TEST_F(DataGridTest, PkNotSelectedIsReadOnly)
{
showQuery("SELECT name FROM grid_parent");
EXPECT_EQ(editableColumns(), (std::vector<bool>{false}));
}
TEST_F(DataGridTest, CompositePkFullyPresentIsEditable)
{
showQuery("SELECT a, b, val FROM grid_composite");
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true, true}));
}
TEST_F(DataGridTest, CompositePkPartiallyPresentIsReadOnly)
{
showQuery("SELECT a, val FROM grid_composite");
EXPECT_EQ(editableColumns(), (std::vector<bool>{false, false}));
}
TEST_F(DataGridTest, WholeTableViaSetConfigLoadsAsynchronously)
{
db = openDb();
auto cls = db->catalog()->classes()->getByName("grid_parent");
ASSERT_NE(cls, nullptr);
model = std::make_unique<CrudModel>();
widget = std::make_unique<DataGridWidget>();
widget->setSourceModel(model.get());
model->setConfig(db, *cls);
model->loadData();
QDeadlineTimer deadline(10000);
while (model->rowCount() < 4 && !deadline.hasExpired())
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
// the whole table view appends an empty row for inserting
ASSERT_EQ(model->rowCount(), 4);
EXPECT_EQ(editableColumns(), (std::vector<bool>{true, true}));
}

View file

@ -1,190 +0,0 @@
#include "DataGridTest.h"
#include <QAction>
#include <QApplication>
#include <QStandardItemModel>
#include <QTimer>
#include <QToolBar>
namespace {
/// Failed saves show a modal message box, this closes it so the test can continue
void autoCloseModalDialogs(int triesLeft = 100)
{
if (auto w = QApplication::activeModalWidget())
w->close();
else if (triesLeft > 0)
QTimer::singleShot(20, [triesLeft] { autoCloseModalDialogs(triesLeft - 1); });
}
/// Records the values of CrudModel::pendingChangesChanged
struct PendingSpy {
explicit PendingSpy(CrudModel *m)
{
QObject::connect(m, &CrudModel::pendingChangesChanged, m, [this](bool b) { values.push_back(b); });
}
std::vector<bool> values;
};
class DataGridUpdateTest : public DataGridTest {
protected:
void edit(int proxyRow, int resultCol, const QString &value)
{
auto proxy = widget->proxy();
ASSERT_TRUE(proxy->setData(proxy->index(proxyRow, resultCol + 1), value, Qt::EditRole));
}
QAction *action(const QString &text)
{
for (auto a : widget->findChildren<QAction *>())
if (a->text() == text)
return a;
return nullptr;
}
};
} // namespace
TEST_F(DataGridUpdateTest, EditSingleCellAndSave)
{
showQuery("SELECT id, name FROM grid_parent");
edit(0, 1, "renamed");
EXPECT_TRUE(model->hasPendingChanges());
action("Save")->trigger();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 1"), "renamed");
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 2"), "beta");
}
TEST_F(DataGridUpdateTest, EditSeveralColumnsAndRows)
{
showQuery("SELECT id, parent_id, note FROM grid_child");
edit(0, 1, "3");
edit(0, 2, "first");
edit(2, 2, "last");
action("Save")->trigger();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(dbValue("SELECT parent_id FROM grid_child WHERE id = 10"), "3");
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 10"), "first");
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 11"), "c2");
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 12"), "last");
}
TEST_F(DataGridUpdateTest, EmptyValueIsSavedAsNull)
{
showQuery("SELECT id, note FROM grid_child");
edit(1, 1, "");
action("Save")->trigger();
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 11"), std::nullopt);
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 10"), "c1");
}
TEST_F(DataGridUpdateTest, JoinUpdatesTheOwningTable)
{
showQuery("SELECT p.id, p.name, c.id AS child_id, c.note FROM grid_parent p JOIN grid_child c ON c.parent_id = p.id");
edit(0, 1, "parent edited");
edit(0, 3, "child edited");
action("Save")->trigger();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 1"), "parent edited");
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 10"), "child edited");
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 2"), "beta");
EXPECT_EQ(dbValue("SELECT note FROM grid_child WHERE id = 11"), "c2");
}
TEST_F(DataGridUpdateTest, CompositePkUpdatesOnlyMatchingRow)
{
showQuery("SELECT a, b, val FROM grid_composite");
edit(1, 2, "changed");
action("Save")->trigger();
EXPECT_EQ(dbValue("SELECT val FROM grid_composite WHERE a = 1 AND b = 2"), "changed");
EXPECT_EQ(dbValue("SELECT val FROM grid_composite WHERE a = 1 AND b = 1"), "x");
EXPECT_EQ(dbValue("SELECT val FROM grid_composite WHERE a = 2 AND b = 1"), "z");
}
TEST_F(DataGridUpdateTest, EditAfterSortingUpdatesTheShownRow)
{
showQuery("SELECT id, name FROM grid_parent");
widget->proxy()->sort(2, Qt::DescendingOrder);
auto proxy = widget->proxy();
ASSERT_EQ(proxy->index(0, 1).data(Qt::DisplayRole).toString(), "3");
ASSERT_EQ(proxy->index(0, 2).data(Qt::DisplayRole).toString(), "gamma");
edit(0, 1, "sorted edit");
action("Save")->trigger();
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 3"), "sorted edit");
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 1"), "alpha");
}
TEST_F(DataGridUpdateTest, SaveAndRevertFollowPendingChanges)
{
showQuery("SELECT id, name FROM grid_parent");
auto save = action("Save");
auto revert = action("Revert");
ASSERT_NE(save, nullptr);
ASSERT_NE(revert, nullptr);
EXPECT_FALSE(save->isEnabled());
EXPECT_FALSE(revert->isEnabled());
PendingSpy spy(model.get());
edit(0, 1, "x");
EXPECT_TRUE(save->isEnabled());
EXPECT_TRUE(revert->isEnabled());
EXPECT_EQ(spy.values, (std::vector<bool>{true}));
save->trigger();
EXPECT_FALSE(save->isEnabled());
EXPECT_FALSE(revert->isEnabled());
EXPECT_EQ(spy.values, (std::vector<bool>{true, false}));
}
TEST_F(DataGridUpdateTest, RevertDiscardsEdits)
{
showQuery("SELECT id, name FROM grid_parent");
edit(0, 1, "discard me");
EXPECT_EQ(widget->proxy()->index(0, 2).data(Qt::EditRole).toString(), "discard me");
PendingSpy spy(model.get());
action("Revert")->trigger();
EXPECT_FALSE(model->hasPendingChanges());
EXPECT_FALSE(action("Save")->isEnabled());
EXPECT_EQ(spy.values, (std::vector<bool>{false}));
EXPECT_EQ(widget->proxy()->index(0, 2).data(Qt::EditRole).toString(), "alpha");
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 1"), "alpha");
}
TEST_F(DataGridUpdateTest, FailedSaveKeepsRowPending)
{
showQuery("SELECT id, name FROM grid_parent");
edit(0, 1, ""); // NULL violates NOT NULL on name
QTimer::singleShot(0, [] { autoCloseModalDialogs(); });
action("Save")->trigger();
EXPECT_TRUE(model->hasPendingChanges());
EXPECT_TRUE(action("Save")->isEnabled());
EXPECT_EQ(dbValue("SELECT name FROM grid_parent WHERE id = 1"), "alpha");
}
TEST_F(DataGridUpdateTest, ToolbarHiddenForNonCrudModel)
{
showQuery("SELECT id, name FROM grid_parent");
auto toolbar = widget->findChild<QToolBar *>();
ASSERT_NE(toolbar, nullptr);
EXPECT_FALSE(toolbar->isHidden());
QStandardItemModel other(1, 1);
widget->setSourceModel(&other);
EXPECT_TRUE(toolbar->isHidden());
EXPECT_FALSE(action("Save")->isEnabled());
widget->setSourceModel(model.get());
EXPECT_FALSE(toolbar->isHidden());
widget->setSourceModel(nullptr);
}

View file

@ -9,8 +9,6 @@ add_executable(pglabtests
tst_escapeConnectionStringValue.cpp
tst_expected.cpp
tst_SqlLexer.cpp
tst_SqlContext.cpp
tst_SqlCompletionProvider.cpp
tst_scopeguard.cpp
tst_CsvWriter.cpp
tst_PasswordManager.cpp
@ -20,13 +18,9 @@ add_executable(pglabtests
tst_TypeMappings.cpp
tst_Range.cpp
ResultSourceMappingTest.cpp
tst_RecordPanel.cpp
tst_SslModeModel.cpp
tst_ConnectionConfigurationWidget.cpp
)
target_link_libraries(pglabtests PRIVATE
pglabapp
pglablib
pgsql
core
@ -38,8 +32,6 @@ target_link_libraries(pglabtests PRIVATE
target_include_directories(pglabtests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/pglab
${CMAKE_SOURCE_DIR}/pglab/crud
)
set_target_properties(pglabtests PROPERTIES

View file

@ -1,13 +1,7 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QtGlobal>
#include <gtest/gtest.h>
int main(int argc, char *argv[])
{
if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM"))
qputenv("QT_QPA_PLATFORM", "offscreen");
QApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,111 +0,0 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
#include <QEvent>
#include <QPushButton>
#include "ConnectionConfig.h"
#include "ConnectionConfigurationWidget.h"
#include "ConnectionController.h"
#include "PrintTo_Qt.h"
class ConnectionConfigurationWidgetTest : public ::testing::Test {
protected:
// SSL settings do not need the controller's database or connection tree initialized.
ConnectionController controller;
};
TEST_F(ConnectionConfigurationWidgetTest, SelectedSslModesRoundTripThroughTheWidget)
{
ConnectionConfigurationWidget widget(&controller);
widget.setData(ConnectionConfig());
auto combo = widget.findChild<QComboBox *>("cmbbxSsl");
ASSERT_NE(combo, nullptr);
EXPECT_FALSE(combo->isEditable());
const QStringList modes = {"disable", "allow", "prefer", "require", "verify-ca", "verify-full"};
for (const auto &mode : modes) {
SCOPED_TRACE(mode.toStdString());
auto index = combo->findData(mode);
ASSERT_GT(index, 0);
combo->setCurrentIndex(index);
EXPECT_EQ(combo->currentText(), mode);
auto saved = widget.data();
EXPECT_EQ(saved.getParameter("sslmode"), mode);
ConnectionConfigurationWidget reopened(&controller);
reopened.setData(saved);
EXPECT_EQ(reopened.data().getParameter("sslmode"), mode);
EXPECT_EQ(reopened.findChild<QComboBox *>("cmbbxSsl")->currentText(), mode);
}
combo->setCurrentIndex(combo->findData(QString()));
EXPECT_TRUE(widget.data().getParameter("sslmode").isEmpty());
EXPECT_EQ(widget.data().getParameters().count("sslmode"), 0);
}
TEST_F(ConnectionConfigurationWidgetTest, LegacyVerificationModesAreCanonicalized)
{
ConnectionConfigurationWidget widget(&controller);
for (const auto &mode : {QStringLiteral("verify_ca"), QStringLiteral("verify_full")}) {
ConnectionConfig config;
config.setParameter("sslmode", mode);
widget.setData(config);
auto canonical = QString(mode).replace('_', '-');
EXPECT_EQ(widget.findChild<QComboBox *>("cmbbxSsl")->currentText(), canonical);
EXPECT_EQ(widget.data().getParameter("sslmode"), canonical);
EXPECT_EQ(config.getParameter("sslmode"), mode);
}
}
TEST_F(ConnectionConfigurationWidgetTest, MissingOrUnknownModesResetToDefault)
{
ConnectionConfigurationWidget widget(&controller);
for (const auto &mode : {QString(), QStringLiteral("unknown")}) {
ConnectionConfig config;
config.setParameter("sslmode", "require");
widget.setData(config);
config.setParameter("sslmode", mode);
widget.setData(config);
EXPECT_EQ(widget.findChild<QComboBox *>("cmbbxSsl")->currentText(), QStringLiteral("Default"));
EXPECT_TRUE(widget.data().getParameter("sslmode").isEmpty());
}
}
TEST_F(ConnectionConfigurationWidgetTest, DialogOnlySavesOnAccept)
{
ConnectionConfig config;
config.setParameter("sslmode", "allow");
for (bool accept : {false, true}) {
bool saved = false;
ConnectionConfigurationWidget::editExistingInWindow(&controller, config,
[&](ConnectionConfigurationWidget &widget) {
saved = true;
EXPECT_EQ(widget.data().getParameter("sslmode"), QStringLiteral("require"));
});
QDialog *dialog = nullptr;
for (auto window : QApplication::topLevelWidgets()) {
if (auto candidate = qobject_cast<QDialog *>(window);
candidate && candidate->findChild<ConnectionConfigurationWidget *>()) {
dialog = candidate;
break;
}
}
ASSERT_NE(dialog, nullptr);
auto combo = dialog->findChild<QComboBox *>("cmbbxSsl");
ASSERT_NE(combo, nullptr);
combo->setCurrentIndex(combo->findData(QStringLiteral("require")));
auto buttons = dialog->findChild<QDialogButtonBox *>();
ASSERT_NE(buttons, nullptr);
buttons->button(accept ? QDialogButtonBox::Ok : QDialogButtonBox::Cancel)->click();
EXPECT_EQ(saved, accept);
EXPECT_EQ(config.getParameter("sslmode"), QStringLiteral("allow"));
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
}

View file

@ -1,144 +0,0 @@
#include <gtest/gtest.h>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QStandardItemModel>
#include <QToolButton>
#include "CustomDataRole.h"
#include "Pgsql_oids.h"
#include "RecordPanel.h"
namespace {
/// Column 0 is the row number column, columns 1 to 3 are data columns
/// id (int4, editable), body (text, editable), ro (int4, read-only)
void fillModel(QStandardItemModel &model)
{
model.setColumnCount(4);
model.setRowCount(1);
model.setHeaderData(0, Qt::Horizontal, QStringLiteral("#"), Qt::DisplayRole);
model.setHeaderData(1, Qt::Horizontal, QStringLiteral("id\nint4"), Qt::DisplayRole);
model.setHeaderData(2, Qt::Horizontal, QStringLiteral("body\ntext"), Qt::DisplayRole);
model.setHeaderData(3, Qt::Horizontal, QStringLiteral("ro\nint4"), Qt::DisplayRole);
model.setData(model.index(0, 1), Pgsql::int4_oid, CustomDataTypeRole);
model.setData(model.index(0, 2), Pgsql::text_oid, CustomDataTypeRole);
model.setData(model.index(0, 3), Pgsql::int4_oid, CustomDataTypeRole);
model.setData(model.index(0, 1), QStringLiteral("1"));
model.setData(model.index(0, 2), QStringLiteral("hello"));
model.setData(model.index(0, 3), QStringLiteral("7"));
model.itemFromIndex(model.index(0, 3))->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
}
}
class RecordPanelTest : public ::testing::Test {
protected:
void SetUp() override
{
model.reset(new QStandardItemModel);
fillModel(*model);
panel.setModel(model.get());
panel.setFirstDataColumn(1);
panel.setCurrentRow(0);
}
std::unique_ptr<QStandardItemModel> model;
RecordPanel panel;
};
TEST_F(RecordPanelTest, OneFieldPerDataColumnSkippingRowNumber)
{
ASSERT_NE(panel.widget(), nullptr);
EXPECT_EQ(panel.widget()->findChildren<QLabel *>().size(), 6);
EXPECT_EQ(panel.widget()->findChildren<QPlainTextEdit *>().size(), 1);
EXPECT_EQ(panel.widget()->findChildren<QLineEdit *>().size(), 2);
}
TEST_F(RecordPanelTest, ShowsValuesOfCurrentRow)
{
auto lines = panel.widget()->findChildren<QLineEdit *>();
auto texts = panel.widget()->findChildren<QPlainTextEdit *>();
ASSERT_EQ(lines.size(), 2);
ASSERT_EQ(texts.size(), 1);
EXPECT_EQ(lines[0]->text(), QStringLiteral("1"));
EXPECT_EQ(texts[0]->toPlainText(), QStringLiteral("hello"));
EXPECT_EQ(lines[1]->text(), QStringLiteral("7"));
}
TEST_F(RecordPanelTest, ReadOnlyWhenCellNotEditable)
{
auto lines = panel.widget()->findChildren<QLineEdit *>();
ASSERT_EQ(lines.size(), 2);
EXPECT_FALSE(lines[0]->isReadOnly());
EXPECT_TRUE(lines[1]->isReadOnly());
}
TEST_F(RecordPanelTest, NullShowsEmptyTextWithNullPlaceholder)
{
model->setData(model->index(0, 1), QVariant(), Qt::EditRole);
panel.setCurrentRow(0);
auto lines = panel.widget()->findChildren<QLineEdit *>();
ASSERT_EQ(lines.size(), 2);
EXPECT_TRUE(lines[0]->text().isEmpty());
EXPECT_EQ(lines[0]->placeholderText(), QStringLiteral("null"));
}
TEST_F(RecordPanelTest, EmptyStringShownAsQuotedEmpty)
{
model->setData(model->index(0, 2), QString(""), Qt::EditRole);
panel.setCurrentRow(0);
auto texts = panel.widget()->findChildren<QPlainTextEdit *>();
ASSERT_EQ(texts.size(), 1);
EXPECT_EQ(texts[0]->toPlainText(), QStringLiteral("''"));
}
TEST_F(RecordPanelTest, EditingFinishedWritesThroughModel)
{
auto lines = panel.widget()->findChildren<QLineEdit *>();
ASSERT_EQ(lines.size(), 2);
lines[0]->setText(QStringLiteral("42"));
emit lines[0]->editingFinished();
EXPECT_EQ(model->index(0, 1).data(Qt::EditRole).toString(), QStringLiteral("42"));
}
TEST_F(RecordPanelTest, DataChangedRefreshesEditor)
{
model->setData(model->index(0, 1), QStringLiteral("5"), Qt::EditRole);
auto lines = panel.widget()->findChildren<QLineEdit *>();
ASSERT_EQ(lines.size(), 2);
EXPECT_EQ(lines[0]->text(), QStringLiteral("5"));
}
TEST_F(RecordPanelTest, NoCurrentRowShowsNothing)
{
panel.setCurrentRow(-1);
EXPECT_EQ(panel.widget(), nullptr);
}
TEST_F(RecordPanelTest, ModelResetClearsPanel)
{
model->clear();
EXPECT_EQ(panel.widget(), nullptr);
}
TEST_F(RecordPanelTest, ExpandButtonGrowsTextBox)
{
auto texts = panel.widget()->findChildren<QPlainTextEdit *>();
auto buttons = panel.widget()->findChildren<QToolButton *>();
ASSERT_EQ(texts.size(), 1);
ASSERT_EQ(buttons.size(), 1);
int collapsed = texts[0]->maximumHeight();
buttons[0]->setChecked(true);
int expanded = texts[0]->maximumHeight();
EXPECT_GT(expanded, collapsed);
buttons[0]->setChecked(false);
EXPECT_EQ(texts[0]->maximumHeight(), collapsed);
}

View file

@ -1,237 +0,0 @@
#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

@ -1,271 +0,0 @@
#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);
}

View file

@ -1,67 +0,0 @@
#include <gtest/gtest.h>
#include <QComboBox>
#include "ConnectionConfig.h"
#include "PrintTo_Qt.h"
#include "SslModeModel.h"
TEST(SslModeModelTest, SelectedModesKeepTheirTextAndCanonicalValue)
{
SslModeModel model;
QComboBox combo;
combo.setModel(&model);
ASSERT_FALSE(combo.isEditable());
const QStringList modes = {"disable", "allow", "prefer", "require", "verify-ca", "verify-full"};
ASSERT_EQ(combo.count(), modes.size() + 1);
for (const auto &mode : modes) {
SCOPED_TRACE(mode.toStdString());
auto index = combo.findData(mode);
ASSERT_GT(index, 0);
combo.setCurrentIndex(index);
EXPECT_EQ(combo.currentText(), mode);
EXPECT_EQ(combo.currentData().toString(), mode);
ConnectionConfig config;
config.setParameter("sslmode", combo.currentData().toString());
EXPECT_EQ(config.getParameter("sslmode"), mode);
EXPECT_TRUE(config.connectionString().contains("sslmode=" + mode));
QComboBox reopened;
reopened.setModel(&model);
reopened.setCurrentIndex(reopened.findData(config.getParameter("sslmode")));
EXPECT_EQ(reopened.currentText(), mode);
EXPECT_EQ(reopened.currentData().toString(), mode);
}
}
TEST(SslModeModelTest, DefaultClearsAnExplicitMode)
{
SslModeModel model;
QComboBox combo;
combo.setModel(&model);
EXPECT_EQ(combo.currentText(), QStringLiteral("Default"));
EXPECT_EQ(combo.currentData().toString(), QString());
EXPECT_EQ(combo.findData(QString()), 0);
ConnectionConfig config;
config.setParameter("sslmode", "require");
combo.setCurrentIndex(combo.findData(QStringLiteral("require")));
combo.setCurrentIndex(combo.findData(QString()));
config.setParameter("sslmode", combo.currentData().toString());
EXPECT_TRUE(config.getParameter("sslmode").isEmpty());
EXPECT_EQ(config.getParameters().count("sslmode"), 0);
EXPECT_FALSE(config.connectionString().contains("sslmode="));
}
TEST(SslModeModelTest, InvalidIndexesAndChildRowsAreEmpty)
{
SslModeModel model;
EXPECT_FALSE(model.data(QModelIndex()).isValid());
EXPECT_FALSE(model.data(model.index(model.rowCount(), 0)).isValid());
EXPECT_EQ(model.rowCount(model.index(0, 0)), 0);
}