diff --git a/QLog.pro b/QLog.pro index f4cd0930..819bc9dd 100644 --- a/QLog.pro +++ b/QLog.pro @@ -160,6 +160,7 @@ SOURCES += \ ui/DxccTableWidget.cpp \ ui/EditActivitiesDialog.cpp \ ui/ExportDialog.cpp \ + ui/SqlQueryDialog.cpp \ ui/ExportPasswordDialog.cpp \ ui/LoadDatabaseDialog.cpp \ ui/PlatformSettingsDialog.cpp \ @@ -199,6 +200,7 @@ SOURCES += \ ui/component/MultiselectCompleter.cpp \ ui/component/RepeatButton.cpp \ ui/component/SmartSearchBox.cpp \ + ui/component/SqlHighlighter.cpp \ ui/component/SwitchButton.cpp HEADERS += \ @@ -324,6 +326,7 @@ HEADERS += \ ui/DxccTableWidget.h \ ui/EditActivitiesDialog.h \ ui/ExportDialog.h \ + ui/SqlQueryDialog.h \ ui/ExportPasswordDialog.h \ ui/LoadDatabaseDialog.h \ ui/PlatformSettingsDialog.h \ @@ -368,6 +371,7 @@ HEADERS += \ ui/component/RepeatButton.h \ ui/component/ShutdownAwareWidget.h \ ui/component/SmartSearchBox.h \ + ui/component/SqlHighlighter.h \ ui/component/StyleItemDelegate.h \ ui/component/SwitchButton.h @@ -388,6 +392,7 @@ FORMS += \ ui/DxWidget.ui \ ui/EditActivitiesDialog.ui \ ui/ExportDialog.ui \ + ui/SqlQueryDialog.ui \ ui/ExportPasswordDialog.ui \ ui/LoadDatabaseDialog.ui \ ui/PlatformSettingsDialog.ui \ diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index 5fb7b679..9343ade0 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -11,6 +11,7 @@ #include "ui/SettingsDialog.h" #include "ui/ImportDialog.h" #include "ui/ExportDialog.h" +#include "ui/SqlQueryDialog.h" #include "core/FldigiTCPServer.h" #include "rig/Rig.h" #include "rotator/Rotator.h" @@ -1100,6 +1101,14 @@ void MainWindow::showQSLGallery() dialog.exec(); } +void MainWindow::showSqlQuery() +{ + FCT_IDENTIFICATION; + + SqlQueryDialog dialog(this); + dialog.exec(); +} + void MainWindow::showDumpDB() { FCT_IDENTIFICATION; diff --git a/ui/MainWindow.h b/ui/MainWindow.h index b8b7a114..19856c16 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -80,6 +80,7 @@ private slots: void showDumpDB(); void showLoadDB(); void showQSLGallery(); + void showSqlQuery(); void saveProfileLayoutGeometry(); void setEquipmentKeepOptions(bool); diff --git a/ui/MainWindow.ui b/ui/MainWindow.ui index 3f52230c..72ea0359 100644 --- a/ui/MainWindow.ui +++ b/ui/MainWindow.ui @@ -80,6 +80,8 @@ + + @@ -463,6 +465,18 @@ QSL &Gallery + + + + .. + + + SQL &Query... + + + Run custom read-only SQL queries against the logbook database + + Wsjtx @@ -1737,6 +1751,22 @@ + + actionSqlQuery + triggered() + MainWindow + showSqlQuery() + + + -1 + -1 + + + 456 + 298 + + + settingsChanged() @@ -1770,5 +1800,6 @@ showDumpDB() showLoadDB() showQSLGallery() + showSqlQuery() diff --git a/ui/SqlQueryDialog.cpp b/ui/SqlQueryDialog.cpp new file mode 100644 index 00000000..41678a8d --- /dev/null +++ b/ui/SqlQueryDialog.cpp @@ -0,0 +1,619 @@ +#include "SqlQueryDialog.h" +#include "ui_SqlQueryDialog.h" +#include "ui/component/SqlHighlighter.h" +#include "ui/ExportDialog.h" +#include "core/debug.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MODULE_IDENTIFICATION("qlog.ui.sqlquerydialog"); + +// DML / DDL keywords that must never appear in a safe query +static const QStringList BLOCKED_KEYWORDS = { + "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", + "ATTACH", "DETACH", "REPLACE", "UPSERT", "TRUNCATE", "RENAME", + "PRAGMA", "VACUUM", "REINDEX", "ANALYZE" +}; + +// Full completion word list (SQL keywords + built-in functions) +static const QStringList SQL_COMPLETIONS = { + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", + "IS", "NULL", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "DISTINCT", "ALL", "AS", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", + "FULL", "CROSS", "ON", "UNION", "INTERSECT", "EXCEPT", "WITH", + "RECURSIVE", "CASE", "WHEN", "THEN", "ELSE", "END", "EXISTS", + "ASC", "DESC", "COLLATE", "CAST", "TRUE", "FALSE", "ROWID", + "NOCASE", "BINARY", + "COUNT", "SUM", "AVG", "MIN", "MAX", "ABS", "LENGTH", "LOWER", "UPPER", + "SUBSTR", "TRIM", "LTRIM", "RTRIM", "REPLACE", "INSTR", "PRINTF", + "ROUND", "COALESCE", "NULLIF", "IFNULL", "IIF", "TYPEOF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "RANDOM", "HEX", "QUOTE", "GROUP_CONCAT", "JSON_EXTRACT" +}; + +// --------------------------------------------------------------------------- +// Construction / destruction +// --------------------------------------------------------------------------- + +SqlQueryDialog::SqlQueryDialog(QWidget *parent) + : QDialog(parent), + ui(new Ui::SqlQueryDialog), + highlighter(nullptr), + completer(nullptr), + completerModel(new QStringListModel(this)), + queryModel(new QSqlQueryModel(this)), + sortProxy(new QSortFilterProxyModel(this)) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + setWindowTitle(tr("SQL Query")); + + // Restore geometry & splitter state + QSettings settings; + restoreGeometry(settings.value("SqlQueryDialog/geometry").toByteArray()); + ui->splitter->restoreState(settings.value("SqlQueryDialog/splitter").toByteArray()); + if (ui->splitter->sizes().value(0, 0) == 0) { + // First run — sensible default split + ui->splitter->setSizes({250, 450}); + } + + // Monospace font for the editor + QFont editorFont = QFontDatabase::systemFont(QFontDatabase::FixedFont); + editorFont.setPointSize(10); + ui->sqlEditor->setFont(editorFont); + + // Syntax highlighter + highlighter = new SqlHighlighter(ui->sqlEditor->document()); + + // Results model + sortable proxy + sortProxy->setSourceModel(queryModel); + ui->resultsTable->setModel(sortProxy); + ui->resultsTable->horizontalHeader()->setStretchLastSection(true); + ui->resultsTable->verticalHeader()->setVisible(false); + + // Autocomplete + completer = new QCompleter(completerModel, this); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setFilterMode(Qt::MatchContains); + completer->setWidget(ui->sqlEditor); + connect(completer, + QOverload::of(&QCompleter::activated), + this, &SqlQueryDialog::insertCompletion); + + // Export drop-down menu + QMenu *exportMenu = new QMenu(this); + connect(exportMenu->addAction(tr("Export as TXT...")), &QAction::triggered, + this, &SqlQueryDialog::exportAsTxt); + connect(exportMenu->addAction(tr("Export as CSV...")), &QAction::triggered, + this, &SqlQueryDialog::exportAsCsv); + connect(exportMenu->addAction(tr("Export as ADIF...")), &QAction::triggered, + this, &SqlQueryDialog::exportAsAdif); + ui->exportButton->setMenu(exportMenu); + + // Toolbar button signals + connect(ui->openButton, &QPushButton::clicked, this, &SqlQueryDialog::openQuery); + connect(ui->saveButton, &QPushButton::clicked, this, &SqlQueryDialog::saveQuery); + connect(ui->runButton, &QPushButton::clicked, this, &SqlQueryDialog::runQuery); + + // Text-change drives live autocomplete + connect(ui->sqlEditor, &QPlainTextEdit::textChanged, + this, &SqlQueryDialog::onTextChanged); + + // Install event filter for F5 / Ctrl+Return / Ctrl+Space / popup navigation + ui->sqlEditor->installEventFilter(this); + + // Load db schema into highlighter + completion list + loadSchema(); +} + +SqlQueryDialog::~SqlQueryDialog() +{ + QSettings settings; + settings.setValue("SqlQueryDialog/geometry", saveGeometry()); + settings.setValue("SqlQueryDialog/splitter", ui->splitter->saveState()); + delete ui; +} + +// --------------------------------------------------------------------------- +// Schema loading +// --------------------------------------------------------------------------- + +void SqlQueryDialog::loadSchema() +{ + FCT_IDENTIFICATION; + + QStringList schemaIds; + + QSqlQuery q; + if (q.exec("SELECT name FROM sqlite_master WHERE type IN ('table','view') ORDER BY name")) { + while (q.next()) { + const QString tableName = q.value(0).toString(); + schemaIds.append(tableName); + + QSqlQuery colQ; + if (colQ.exec(QString("PRAGMA table_info(%1)").arg(tableName))) { + while (colQ.next()) { + const QString col = colQ.value(1).toString(); + if (!schemaIds.contains(col, Qt::CaseInsensitive)) + schemaIds.append(col); + } + } + } + } + + // Combined completion list: SQL keywords + schema identifiers, sorted + QStringList combined = SQL_COMPLETIONS; + for (const QString &id : qAsConst(schemaIds)) { + if (!combined.contains(id, Qt::CaseInsensitive)) + combined.append(id); + } + combined.sort(Qt::CaseInsensitive); + completerModel->setStringList(combined); + + // Highlight schema names in a distinct colour + highlighter->setUserIdentifiers(schemaIds); +} + +// --------------------------------------------------------------------------- +// Safety check — only pure SELECT / WITH … SELECT are allowed +// --------------------------------------------------------------------------- + +bool SqlQueryDialog::isSafeQuery(const QString &sql) const +{ + // Strip single-line comments + static const QRegularExpression slComment("--[^\n]*"); + // Strip block comments (non-greedy, dotall) + static const QRegularExpression mlComment( + "/\\*.*?\\*/", + QRegularExpression::DotMatchesEverythingOption); + + QString stripped = sql; + stripped.remove(slComment); + stripped.remove(mlComment); + stripped = stripped.trimmed(); + + // Must start with SELECT or WITH (for CTEs that end in SELECT) + if (!stripped.startsWith("SELECT", Qt::CaseInsensitive) && + !stripped.startsWith("WITH", Qt::CaseInsensitive)) + return false; + + // Reject any blocked keyword appearing as a whole word + for (const QString &kw : BLOCKED_KEYWORDS) { + const QRegularExpression re( + QString("\\b%1\\b").arg(kw), + QRegularExpression::CaseInsensitiveOption); + if (re.match(stripped).hasMatch()) + return false; + } + + return true; +} + +// --------------------------------------------------------------------------- +// Autocomplete helpers +// --------------------------------------------------------------------------- + +QString SqlQueryDialog::currentWord() const +{ + QTextCursor cursor = ui->sqlEditor->textCursor(); + cursor.select(QTextCursor::WordUnderCursor); + return cursor.selectedText(); +} + +void SqlQueryDialog::showCompleterPopup() +{ + QRect cr = ui->sqlEditor->cursorRect(); + cr.setWidth( + completer->popup()->sizeHintForColumn(0) + + completer->popup()->verticalScrollBar()->sizeHint().width()); + completer->complete(cr); +} + +// Called whenever the editor text changes — drives live completion +void SqlQueryDialog::onTextChanged() +{ + // Don't interfere while the user is navigating the popup + if (completer->popup()->isVisible() && + completer->popup()->currentIndex().isValid()) + return; + + const QString word = currentWord(); + if (word.length() < 2) { + completer->popup()->hide(); + return; + } + + if (word != completer->completionPrefix()) { + completer->setCompletionPrefix(word); + if (completer->completionCount() > 0) + showCompleterPopup(); + else + completer->popup()->hide(); + } +} + +// Insert the chosen completion, replacing the partial word already typed +void SqlQueryDialog::insertCompletion(const QString &completion) +{ + FCT_IDENTIFICATION; + + QTextCursor cursor = ui->sqlEditor->textCursor(); + const int extra = completion.length() - completer->completionPrefix().length(); + cursor.movePosition(QTextCursor::Left); + cursor.movePosition(QTextCursor::EndOfWord); + cursor.insertText(completion.right(extra)); + ui->sqlEditor->setTextCursor(cursor); +} + +// --------------------------------------------------------------------------- +// Event filter — keyboard shortcuts & popup navigation +// --------------------------------------------------------------------------- + +bool SqlQueryDialog::eventFilter(QObject *obj, QEvent *event) +{ + if (obj != ui->sqlEditor || event->type() != QEvent::KeyPress) + return QDialog::eventFilter(obj, event); + + QKeyEvent *ke = static_cast(event); + + // When the completion popup is open, forward navigation keys to it + if (completer->popup()->isVisible()) { + switch (ke->key()) { + case Qt::Key_Enter: + case Qt::Key_Return: + case Qt::Key_Escape: + case Qt::Key_Tab: + case Qt::Key_Backtab: + case Qt::Key_Up: + case Qt::Key_Down: + case Qt::Key_PageUp: + case Qt::Key_PageDown: + QApplication::sendEvent(completer->popup(), event); + return true; + default: + break; + } + } + + // F5 or Ctrl+Return → run query + if (ke->key() == Qt::Key_F5 || + (ke->key() == Qt::Key_Return && + (ke->modifiers() & Qt::ControlModifier))) { + runQuery(); + return true; + } + + // Ctrl+Space → force-show completer with current word as prefix + if (ke->key() == Qt::Key_Space && + (ke->modifiers() & Qt::ControlModifier)) { + const QString word = currentWord(); + completer->setCompletionPrefix(word); + if (completer->completionCount() == 0) + completer->setCompletionPrefix(""); + showCompleterPopup(); + return true; + } + + return false; // let the editor handle everything else normally +} + +// --------------------------------------------------------------------------- +// File operations +// --------------------------------------------------------------------------- + +void SqlQueryDialog::openQuery() +{ + FCT_IDENTIFICATION; + + QSettings settings; + const QString lastDir = + settings.value("SqlQueryDialog/lastDir", QDir::homePath()).toString(); + + const QString filename = QFileDialog::getOpenFileName( + this, tr("Open SQL Query"), lastDir, + tr("SQL Files (*.sql);;All Files (*)")); + + if (filename.isEmpty()) + return; + + QFile file(filename); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Open Error"), + tr("Cannot open file:\n%1").arg(file.errorString())); + return; + } + + QTextStream in(&file); + ui->sqlEditor->setPlainText(in.readAll()); + settings.setValue("SqlQueryDialog/lastDir", QFileInfo(filename).absolutePath()); +} + +void SqlQueryDialog::saveQuery() +{ + FCT_IDENTIFICATION; + + QSettings settings; + const QString lastDir = + settings.value("SqlQueryDialog/lastDir", QDir::homePath()).toString(); + + QString filename = QFileDialog::getSaveFileName( + this, tr("Save SQL Query"), lastDir, + tr("SQL Files (*.sql);;All Files (*)")); + + if (filename.isEmpty()) + return; + + if (!filename.endsWith(".sql", Qt::CaseInsensitive)) + filename += ".sql"; + + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Save Error"), + tr("Cannot save file:\n%1").arg(file.errorString())); + return; + } + + QTextStream out(&file); + out << ui->sqlEditor->toPlainText(); + settings.setValue("SqlQueryDialog/lastDir", QFileInfo(filename).absolutePath()); + ui->statusLabel->setText(tr("Query saved to %1").arg(QFileInfo(filename).fileName())); +} + +// --------------------------------------------------------------------------- +// Run query +// --------------------------------------------------------------------------- + +void SqlQueryDialog::runQuery() +{ + FCT_IDENTIFICATION; + + const QString sql = ui->sqlEditor->toPlainText().trimmed(); + if (sql.isEmpty()) + return; + + if (!isSafeQuery(sql)) { + ui->statusLabel->setText( + tr("Blocked — only read-only SELECT queries are allowed.")); + QMessageBox::warning(this, tr("Query Not Allowed"), + tr("Only read-only SELECT queries are permitted.\n\n" + "The following statement types are blocked:\n" + "INSERT, UPDATE, DELETE, DROP, CREATE, ALTER,\n" + "ATTACH, DETACH, PRAGMA, VACUUM, and similar.")); + return; + } + + QElapsedTimer timer; + timer.start(); + + queryModel->setQuery(sql, QSqlDatabase::database()); + + if (queryModel->lastError().isValid()) { + ui->statusLabel->setText( + tr("Error: %1").arg(queryModel->lastError().text())); + return; + } + + // Force-fetch all rows (model is lazily populated by default) + while (queryModel->canFetchMore()) + queryModel->fetchMore(); + + const qint64 elapsed = timer.elapsed(); + const int rows = queryModel->rowCount(); + + ui->resultsTable->resizeColumnsToContents(); + ui->statusLabel->setText( + tr("%1 row(s) returned in %2 ms").arg(rows).arg(elapsed)); +} + +// --------------------------------------------------------------------------- +// Export helpers +// --------------------------------------------------------------------------- + +static bool openFileForWrite(QWidget *parent, + const QString &caption, + const QString &filter, + const QString &defaultSuffix, + QString &outPath) +{ + QSettings settings; + const QString lastDir = + settings.value("SqlQueryDialog/lastDir", QDir::homePath()).toString(); + + QString path = QFileDialog::getSaveFileName(parent, caption, lastDir, filter); + if (path.isEmpty()) + return false; + + if (!defaultSuffix.isEmpty() && + !path.endsWith('.' + defaultSuffix, Qt::CaseInsensitive)) + path += '.' + defaultSuffix; + + settings.setValue("SqlQueryDialog/lastDir", QFileInfo(path).absolutePath()); + outPath = path; + return true; +} + +void SqlQueryDialog::exportAsTxt() +{ + FCT_IDENTIFICATION; + + if (queryModel->rowCount() == 0) { + QMessageBox::information(this, tr("Export"), + tr("No results to export — run a query first.")); + return; + } + + QString filename; + if (!openFileForWrite(this, tr("Export as TXT"), + tr("Text Files (*.txt);;All Files (*)"), "txt", filename)) + return; + + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Export Error"), + tr("Cannot open file for writing:\n%1").arg(file.errorString())); + return; + } + + QTextStream out(&file); + const int cols = queryModel->columnCount(); + + // Header row + QStringList headers; + for (int c = 0; c < cols; ++c) + headers << queryModel->headerData(c, Qt::Horizontal).toString(); + out << headers.join("\t") << "\n"; + + // Data rows + for (int r = 0; r < queryModel->rowCount(); ++r) { + QStringList row; + for (int c = 0; c < cols; ++c) + row << queryModel->data(queryModel->index(r, c)).toString(); + out << row.join("\t") << "\n"; + } + + ui->statusLabel->setText( + tr("Exported %1 row(s) to %2") + .arg(queryModel->rowCount()) + .arg(QFileInfo(filename).fileName())); +} + +void SqlQueryDialog::exportAsCsv() +{ + FCT_IDENTIFICATION; + + if (queryModel->rowCount() == 0) { + QMessageBox::information(this, tr("Export"), + tr("No results to export — run a query first.")); + return; + } + + QString filename; + if (!openFileForWrite(this, tr("Export as CSV"), + tr("CSV Files (*.csv);;All Files (*)"), "csv", filename)) + return; + + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Export Error"), + tr("Cannot open file for writing:\n%1").arg(file.errorString())); + return; + } + + QTextStream out(&file); + const int cols = queryModel->columnCount(); + + auto csvEscape = [](const QString &s) -> QString { + if (s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r')) + return '"' + QString(s).replace('"', "\"\"") + '"'; + return s; + }; + + // Header row + QStringList headers; + for (int c = 0; c < cols; ++c) + headers << csvEscape(queryModel->headerData(c, Qt::Horizontal).toString()); + out << headers.join(",") << "\n"; + + // Data rows + for (int r = 0; r < queryModel->rowCount(); ++r) { + QStringList row; + for (int c = 0; c < cols; ++c) + row << csvEscape(queryModel->data(queryModel->index(r, c)).toString()); + out << row.join(",") << "\n"; + } + + ui->statusLabel->setText( + tr("Exported %1 row(s) to %2") + .arg(queryModel->rowCount()) + .arg(QFileInfo(filename).fileName())); +} + +void SqlQueryDialog::exportAsAdif() +{ + FCT_IDENTIFICATION; + + if (queryModel->rowCount() == 0) { + QMessageBox::information(this, tr("Export"), + tr("No results to export — run a query first.")); + return; + } + + // Locate an 'id' column in the result set + int idCol = -1; + for (int c = 0; c < queryModel->columnCount(); ++c) { + if (queryModel->headerData(c, Qt::Horizontal) + .toString().compare("id", Qt::CaseInsensitive) == 0) { + idCol = c; + break; + } + } + + if (idCol < 0) { + QMessageBox::warning(this, tr("ADIF Export"), + tr("ADIF export requires the query to include the contacts 'id' column.\n\n" + "Example:\n" + " SELECT id, callsign FROM contacts WHERE ...")); + return; + } + + // Collect all contact IDs from the result + QStringList idStrings; + for (int r = 0; r < queryModel->rowCount(); ++r) { + const QVariant id = queryModel->data(queryModel->index(r, idCol)); + if (id.isValid() && !id.isNull()) + idStrings << id.toString(); + } + + if (idStrings.isEmpty()) { + QMessageBox::information(this, tr("ADIF Export"), + tr("No valid contact IDs found in the result set.")); + return; + } + + // Fetch the full contact records for those IDs + const QString fetchSql = + QString("SELECT * FROM contacts WHERE id IN (%1) ORDER BY start_time ASC") + .arg(idStrings.join(',')); + + QSqlQuery fetchQ; + if (!fetchQ.exec(fetchSql)) { + QMessageBox::warning(this, tr("ADIF Export"), + tr("Failed to retrieve contact records:\n%1") + .arg(fetchQ.lastError().text())); + return; + } + + QList records; + while (fetchQ.next()) + records.append(fetchQ.record()); + + if (records.isEmpty()) { + QMessageBox::information(this, tr("ADIF Export"), + tr("No matching contacts found in the database.")); + return; + } + + // Hand off to the standard ExportDialog (same path as LogbookWidget right-click) + ExportDialog dialog(records, this); + dialog.exec(); +} diff --git a/ui/SqlQueryDialog.h b/ui/SqlQueryDialog.h new file mode 100644 index 00000000..81bd54ab --- /dev/null +++ b/ui/SqlQueryDialog.h @@ -0,0 +1,51 @@ +#ifndef QLOG_UI_SQLQUERYDIALOG_H +#define QLOG_UI_SQLQUERYDIALOG_H + +#include +#include +#include +#include +#include + +namespace Ui { +class SqlQueryDialog; +} + +class SqlHighlighter; + +class SqlQueryDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SqlQueryDialog(QWidget *parent = nullptr); + ~SqlQueryDialog(); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void openQuery(); + void saveQuery(); + void runQuery(); + void exportAsTxt(); + void exportAsCsv(); + void exportAsAdif(); + void insertCompletion(const QString &completion); + void onTextChanged(); + +private: + Ui::SqlQueryDialog *ui; + SqlHighlighter *highlighter; + QCompleter *completer; + QStringListModel *completerModel; + QSqlQueryModel *queryModel; + QSortFilterProxyModel *sortProxy; + + void loadSchema(); + bool isSafeQuery(const QString &sql) const; + QString currentWord() const; + void showCompleterPopup(); +}; + +#endif // QLOG_UI_SQLQUERYDIALOG_H diff --git a/ui/SqlQueryDialog.ui b/ui/SqlQueryDialog.ui new file mode 100644 index 00000000..f71bf2a9 --- /dev/null +++ b/ui/SqlQueryDialog.ui @@ -0,0 +1,155 @@ + + + SqlQueryDialog + + + + 0 + 0 + 950 + 720 + + + + SQL Query + + + + 4 + + + + + 4 + + + + + &Open... + + + Open a saved SQL query file + + + + + + + &Save... + + + Save the current query to a .sql file + + + + + + + QFrame::VLine + + + QFrame::Sunken + + + + + + + &Run (F5) + + + Execute the query (F5 or Ctrl+Return) + + + false + + + + + + + QFrame::VLine + + + QFrame::Sunken + + + + + + + Export ▾ + + + Export query results + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + + 0 + 100 + + + + QPlainTextEdit::NoWrap + + + Enter SELECT query here... Ctrl+Space = autocomplete F5 or Ctrl+Return = run + + + + + + 0 + 100 + + + + QAbstractItemView::SelectRows + + + true + + + true + + + QAbstractItemView::ScrollPerPixel + + + + + + + + Ready — only SELECT queries are permitted + + + + + + + + diff --git a/ui/component/SqlHighlighter.cpp b/ui/component/SqlHighlighter.cpp new file mode 100644 index 00000000..b0bb07ba --- /dev/null +++ b/ui/component/SqlHighlighter.cpp @@ -0,0 +1,170 @@ +#include "SqlHighlighter.h" +#include "core/debug.h" + +#include +#include +#include +#include + +MODULE_IDENTIFICATION("qlog.ui.component.sqlhighlighter"); + +// Detect dark mode by checking whether the window background is darker than mid-gray. +static bool detectDarkMode() +{ + return QApplication::palette().window().color().lightness() < 128; +} + +SqlHighlighter::SqlHighlighter(QTextDocument *parent) + : QSyntaxHighlighter(parent), + m_isDark(detectDarkMode()) +{ + // ----------------------------------------------------------------------- + // Color palette — two sets so both themes are readable. + // + // Dark colours lifted from VS Code "Dark+" defaults. + // Light colours lifted from VS Code "Light+" defaults. + // ----------------------------------------------------------------------- + const QColor keywordColor = m_isDark ? QColor(86, 156, 214) : QColor( 0, 0, 187); + const QColor functionColor = m_isDark ? QColor(220, 220, 170) : QColor(121, 94, 38); + const QColor stringColor = m_isDark ? QColor(206, 145, 120) : QColor(163, 21, 21); + const QColor numberColor = m_isDark ? QColor(181, 206, 168) : QColor( 9, 134, 88); + const QColor commentColor = m_isDark ? QColor(106, 153, 85) : QColor( 10, 121, 10); + + // SQL keywords — bold + QTextCharFormat keywordFormat; + keywordFormat.setForeground(keywordColor); + keywordFormat.setFontWeight(QFont::Bold); + + const QStringList keywords = { + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", + "IS", "NULL", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "DISTINCT", "ALL", "AS", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", + "FULL", "CROSS", "ON", "UNION", "INTERSECT", "EXCEPT", "WITH", + "RECURSIVE", "CASE", "WHEN", "THEN", "ELSE", "END", "EXISTS", + "ASC", "DESC", "COLLATE", "CAST", "TRUE", "FALSE", "ROWID", + "NOCASE", "BINARY", "RTRIM" + }; + + for (const QString &kw : keywords) { + Rule rule; + rule.pattern = QRegularExpression( + QString("\\b%1\\b").arg(kw), + QRegularExpression::CaseInsensitiveOption); + rule.format = keywordFormat; + rules.append(rule); + } + + // Built-in functions + QTextCharFormat functionFormat; + functionFormat.setForeground(functionColor); + + const QStringList functions = { + "COUNT", "SUM", "AVG", "MIN", "MAX", "ABS", "LENGTH", "LOWER", "UPPER", + "SUBSTR", "TRIM", "LTRIM", "RTRIM", "REPLACE", "INSTR", "PRINTF", + "ROUND", "COALESCE", "NULLIF", "IFNULL", "IIF", "TYPEOF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "RANDOM", "HEX", "QUOTE", "GROUP_CONCAT", "JSON_EXTRACT" + }; + + for (const QString &fn : functions) { + Rule rule; + rule.pattern = QRegularExpression( + QString("\\b%1\\b").arg(fn), + QRegularExpression::CaseInsensitiveOption); + rule.format = functionFormat; + rules.append(rule); + } + + // Single-quoted strings + QTextCharFormat stringFormat; + stringFormat.setForeground(stringColor); + Rule stringRule; + stringRule.pattern = QRegularExpression("'[^']*'"); + stringRule.format = stringFormat; + rules.append(stringRule); + + // Numbers + QTextCharFormat numberFormat; + numberFormat.setForeground(numberColor); + Rule numberRule; + numberRule.pattern = QRegularExpression("\\b[0-9]+(\\.[0-9]+)?\\b"); + numberRule.format = numberFormat; + rules.append(numberRule); + + // Single-line comments (--) + QTextCharFormat singleLineCommentFormat; + singleLineCommentFormat.setForeground(commentColor); + Rule slComment; + slComment.pattern = QRegularExpression("--[^\n]*"); + slComment.format = singleLineCommentFormat; + rules.append(slComment); + + // Multi-line block comment format and delimiters + multiLineCommentFormat.setForeground(commentColor); + blockCommentStart = QRegularExpression("/\\*"); + blockCommentEnd = QRegularExpression("\\*/"); + + // identifierColor is applied in setUserIdentifiers() using m_isDark +} + +void SqlHighlighter::setUserIdentifiers(const QStringList &identifiers) +{ + identifierRules.clear(); + + // Re-derive the identifier colour from m_isDark so it stays in sync. + const QColor identifierColor = m_isDark ? QColor(156, 220, 254) : QColor(0, 16, 128); + + QTextCharFormat identifierFormat; + identifierFormat.setForeground(identifierColor); + + for (const QString &id : identifiers) { + Rule rule; + rule.pattern = QRegularExpression( + QString("\\b%1\\b").arg(QRegularExpression::escape(id)), + QRegularExpression::CaseInsensitiveOption); + rule.format = identifierFormat; + identifierRules.append(rule); + } + + rehighlight(); +} + +void SqlHighlighter::highlightBlock(const QString &text) +{ + // Schema identifiers first (lowest priority — keywords override them below) + for (const Rule &rule : qAsConst(identifierRules)) { + QRegularExpressionMatchIterator it = rule.pattern.globalMatch(text); + while (it.hasNext()) { + QRegularExpressionMatch m = it.next(); + setFormat(m.capturedStart(), m.capturedLength(), rule.format); + } + } + + // Keywords, functions, strings, numbers, single-line comments + for (const Rule &rule : qAsConst(rules)) { + QRegularExpressionMatchIterator it = rule.pattern.globalMatch(text); + while (it.hasNext()) { + QRegularExpressionMatch m = it.next(); + setFormat(m.capturedStart(), m.capturedLength(), rule.format); + } + } + + // Multi-line block comments — tracked across blocks via block state + setCurrentBlockState(0); + + int startIndex = (previousBlockState() == 1) ? 0 : text.indexOf(blockCommentStart); + + while (startIndex >= 0) { + QRegularExpressionMatch endMatch = blockCommentEnd.match(text, startIndex); + int endIndex = endMatch.capturedStart(); + int len; + if (endIndex == -1) { + setCurrentBlockState(1); + len = text.length() - startIndex; + } else { + len = endIndex - startIndex + endMatch.capturedLength(); + } + setFormat(startIndex, len, multiLineCommentFormat); + startIndex = text.indexOf(blockCommentStart, startIndex + len); + } +} diff --git a/ui/component/SqlHighlighter.h b/ui/component/SqlHighlighter.h new file mode 100644 index 00000000..e1d1583e --- /dev/null +++ b/ui/component/SqlHighlighter.h @@ -0,0 +1,36 @@ +#ifndef QLOG_UI_COMPONENT_SQLHIGHLIGHTER_H +#define QLOG_UI_COMPONENT_SQLHIGHLIGHTER_H + +#include +#include +#include +#include + +class SqlHighlighter : public QSyntaxHighlighter +{ + Q_OBJECT + +public: + explicit SqlHighlighter(QTextDocument *parent = nullptr); + void setUserIdentifiers(const QStringList &identifiers); + +protected: + void highlightBlock(const QString &text) override; + +private: + struct Rule { + QRegularExpression pattern; + QTextCharFormat format; + }; + + QVector rules; + QVector identifierRules; + + QTextCharFormat multiLineCommentFormat; + QRegularExpression blockCommentStart; + QRegularExpression blockCommentEnd; + + bool m_isDark; +}; + +#endif // QLOG_UI_COMPONENT_SQLHIGHLIGHTER_H