Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ target_sources(${PROJECT_NAME}
src/ColumnDisplayFormatDialog.h
src/FilterLineEdit.h
src/GlobalFilter.h
src/ExplainTree.h
src/ForeignKeyEditorDelegate.h
src/PlotDock.h
src/FindReplaceDialog.h
Expand Down Expand Up @@ -199,6 +200,7 @@ target_sources(${PROJECT_NAME}
src/ColumnDisplayFormatDialog.cpp
src/FilterLineEdit.cpp
src/GlobalFilter.cpp
src/ExplainTree.cpp
src/ForeignKeyEditorDelegate.cpp
src/PlotDock.cpp
src/FindReplaceDialog.cpp
Expand Down
131 changes: 131 additions & 0 deletions src/ExplainTree.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#include "ExplainTree.h"

#include <QModelIndex>
#include <QRegularExpression>

#include <map>

namespace ExplainTree
{

bool isExplainQueryPlan(const QString& sql)
{
static const QRegularExpression re("^\\s*EXPLAIN\\s+QUERY\\s+PLAN\\b", QRegularExpression::CaseInsensitiveOption);
return re.match(sql).hasMatch();
}

std::vector<TreeLine> buildTree(const std::vector<PlanRow>& rows)
{
const size_t count = rows.size();

// Find the position of the parent of each row. If an id is used more than once, the first row wins.
std::map<int, size_t> position;
for(size_t i = 0; i < count; ++i)
position.insert({rows[i].id, i});

const size_t none = count;
std::vector<size_t> parentOf(count, none);
std::vector<std::vector<size_t>> children(count);
for(size_t i = 0; i < count; ++i)
{
auto it = position.find(rows[i].parent);
if(it != position.end() && it->second != i)
{
parentOf[i] = it->second;
children[it->second].push_back(i);
}
}

std::vector<TreeLine> result;
result.reserve(count);
std::vector<bool> visited(count, false);

// Depth-first walk without recursion; the stack holds rows still to be placed, the last one is placed next
auto walk = [&](size_t root) {
std::vector<std::pair<size_t, int>> stack;
stack.push_back({root, 0});
while(!stack.empty())
{
const auto current = stack.back();
stack.pop_back();
if(visited[current.first])
continue;
visited[current.first] = true;
result.push_back({current.first, current.second, rows[current.first].detail});

const auto& kids = children[current.first];
for(auto it = kids.rbegin(); it != kids.rend(); ++it)
stack.push_back({*it, current.second + 1});
}
};

for(size_t i = 0; i < count; ++i)
if(parentOf[i] == none)
walk(i);

// Rows which are part of a parent loop are not reachable from any top level row. Don't lose them.
for(size_t i = 0; i < count; ++i)
if(!visited[i])
walk(i);

return result;
}

std::vector<int> depths(const std::vector<PlanRow>& rows)
{
std::vector<int> result(rows.size(), 0);
for(const auto& line : buildTree(rows))
result[line.row] = line.depth;
return result;
}

QString indent(const QString& detail, int depth)
{
if(depth <= 0)
return detail;

// Three characters per level, the last one is a branch marker
return QString(3 * (depth - 1), ' ') + QChar(0x2514) + QChar(0x2500) + ' ' + detail;
}

Delegate::Delegate(QObject* parent)
: QStyledItemDelegate(parent)
{
}

QString Delegate::indentedText(const QModelIndex& index) const
{
const QString text = index.data().toString();

// Only the detail column of a result with the four EXPLAIN QUERY PLAN columns is indented
const QAbstractItemModel* model = index.model();
if(!model || model->columnCount() != 4 || index.column() != 3)
return text;

// Collect the steps which are available in the model. Rows which are not loaded yet are left out.
std::vector<PlanRow> rows;
size_t target = static_cast<size_t>(-1);
for(int r = 0; r < model->rowCount(); ++r)
{
bool idOk = false, parentOk = false;
const int id = model->index(r, 0).data().toString().toInt(&idOk);
const int parent = model->index(r, 1).data().toString().toInt(&parentOk);
if(!idOk || !parentOk)
continue;
if(r == index.row())
target = rows.size();
rows.push_back({id, parent, QString()});
}
if(target >= rows.size())
return text;

return indent(text, depths(rows)[target]);
}

void Delegate::initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const
{
QStyledItemDelegate::initStyleOption(option, index);
option->text = indentedText(index);
}

}
54 changes: 54 additions & 0 deletions src/ExplainTree.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#ifndef EXPLAINTREE_H
#define EXPLAINTREE_H

#include <QString>
#include <QStyledItemDelegate>
#include <vector>

// Helpers for presenting the result of EXPLAIN QUERY PLAN as a tree. SQLite returns one row per plan step with the
// columns (id, parent, notused, detail) where parent refers to the id of the enclosing step (0 for top level steps).
namespace ExplainTree
{

struct PlanRow
{
int id;
int parent;
QString detail;
};

struct TreeLine
{
size_t row; // Index of the step in the input vector
int depth; // Nesting level, 0 for top level steps
QString detail;
};

// Returns true if the statement is an EXPLAIN QUERY PLAN statement.
bool isExplainQueryPlan(const QString& sql);

// Sorts the steps into depth-first order (siblings keep their input order) and assigns each a depth. Steps whose
// parent can't be found are treated as top level steps. Every input step is returned exactly once.
std::vector<TreeLine> buildTree(const std::vector<PlanRow>& rows);

// Returns the nesting depth of each step in the order of the input rows.
std::vector<int> depths(const std::vector<PlanRow>& rows);

// Returns the detail text prefixed with indentation for the given depth.
QString indent(const QString& detail, int depth);

// Item delegate for the detail column of an EXPLAIN QUERY PLAN result (id, parent, notused, detail). It shows the
// text indented by the nesting depth of the row without changing the model data.
class Delegate : public QStyledItemDelegate
{
public:
explicit Delegate(QObject* parent = nullptr);

QString indentedText(const QModelIndex& index) const;

void initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const override;
};

}

#endif
1 change: 1 addition & 0 deletions src/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,7 @@ void MainWindow::executeQuery()
ui->actionSqlResultsSaveAsView->setEnabled(!db.readOnly());

auto * model = sqlWidget->getModel();
sqlWidget->updateResultPresentation(query);
model->setQuery(query);

// Wait until the initial loading of data (= first chunk and row count) has been performed
Expand Down
17 changes: 17 additions & 0 deletions src/SqlExecutionArea.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "Settings.h"
#include "ExportDataDialog.h"
#include "FilterTableHeader.h"
#include "ExplainTree.h"

#include <QInputDialog>
#include <QMessageBox>
Expand All @@ -15,6 +16,7 @@
SqlExecutionArea::SqlExecutionArea(DBBrowserDB& _db, QWidget* parent) :
QWidget(parent),
db(_db),
explainTreeDelegate(nullptr),
ui(new Ui::SqlExecutionArea),
m_columnsResized(false),
error_state(false),
Expand Down Expand Up @@ -103,6 +105,21 @@ void SqlExecutionArea::finishExecution(const QString& result, const bool ok)

}

void SqlExecutionArea::updateResultPresentation(const QString& query)
{
// The result of EXPLAIN QUERY PLAN has the columns id, parent, notused and detail. Show the last one indented by
// the nesting level of the plan step. All other results keep the default delegate of the view.
const int detailColumn = 3;
if(ExplainTree::isExplainQueryPlan(query))
{
if(!explainTreeDelegate)
explainTreeDelegate = new ExplainTree::Delegate(this);
ui->tableResult->setItemDelegateForColumn(detailColumn, explainTreeDelegate);
} else if(explainTreeDelegate) {
ui->tableResult->setItemDelegateForColumn(detailColumn, nullptr);
}
}

void SqlExecutionArea::fetchedData()
{
// Don't resize the columns more than once to fit their contents. This is necessary because the finishedFetch signal of the model
Expand Down
6 changes: 6 additions & 0 deletions src/SqlExecutionArea.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class SqlTextEdit;
class SqliteTableModel;
class DBBrowserDB;
class ExtendedTableWidget;
namespace ExplainTree { class Delegate; }

class QTextEdit;

Expand Down Expand Up @@ -38,6 +39,10 @@ class SqlExecutionArea : public QWidget
ExtendedTableWidget *getTableResult();
QTextEdit* getStatusEdit();

// Shows the detail column of the result indented as a tree if the query is an EXPLAIN QUERY PLAN statement
// and in the normal way otherwise
void updateResultPresentation(const QString& query);

bool inErrorState() const { return error_state; }

// Save window state to settings
Expand Down Expand Up @@ -66,6 +71,7 @@ private slots:
void find(QString expr, bool forward);
DBBrowserDB& db;
SqliteTableModel* model;
ExplainTree::Delegate* explainTreeDelegate;
QString sqlFileName;
QFileSystemWatcher fileSystemWatch;
Ui::SqlExecutionArea* ui;
Expand Down
36 changes: 36 additions & 0 deletions src/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,39 @@ set(TESTCACHE_HDR
add_executable(test-cache ${TESTCACHE_HDR} ${TESTCACHE_SRC})
target_link_libraries(test-cache ${QT_MAJOR}::Test)
add_test(NAME test-cache COMMAND $<TARGET_FILE:test-cache>)

# test explain tree

set(TESTEXPLAINTREE_SRC
TestExplainTree.cpp
../ExplainTree.cpp
)

set(TESTEXPLAINTREE_HDR
TestExplainTree.h
../ExplainTree.h
)

add_executable(test-explain-tree ${TESTEXPLAINTREE_HDR} ${TESTEXPLAINTREE_SRC})
if(QT_MAJOR STREQUAL "Qt6")
set_target_properties(test-explain-tree PROPERTIES AUTOUIC_OPTIONS "--connections=string")
endif()
target_link_libraries(test-explain-tree ${QT_MAJOR}::Test ${QT_MAJOR}::Widgets ${LIBSQLITE_NAME})
add_test(NAME test-explain-tree COMMAND $<TARGET_FILE:test-explain-tree>)
set(TESTEXPLAINTREE_ENV "QT_QPA_PLATFORM=offscreen")
if(WIN32)
set(_et_extra_paths "")
foreach(_p IN LISTS CMAKE_PREFIX_PATH)
list(APPEND _et_extra_paths "${_p}" "${_p}/bin")
# OpenSSL 3 Universal installer layout: bin/x86, bin/x64
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
list(APPEND _et_extra_paths "${_p}/bin/x64")
else()
list(APPEND _et_extra_paths "${_p}/bin/x86")
endif()
endforeach()
# SQLite and SQLCipher are built as DLLs in the CMake prefix by the Windows CI jobs.
string(REPLACE ";" "\\;" TESTEXPLAINTREE_PATH "${_et_extra_paths};$ENV{PATH}")
list(APPEND TESTEXPLAINTREE_ENV "PATH=${TESTEXPLAINTREE_PATH}")
endif()
set_tests_properties(test-explain-tree PROPERTIES ENVIRONMENT "${TESTEXPLAINTREE_ENV}")
Loading
Loading