Add read-only enforcement, preview, import/export to custom SQL reports

Refs discussion #886: a saved-report manager built on custom SQL and
nomenclature.json. Most of what was asked for already existed --
Projet > Exporter au format CSV already builds/saves/reuses named
SELECT queries via ElementQueryWidget and nomenclature.json, and
Projet > Ajouter une nomenclature already inserts one into a folio.
This fills the three real gaps.

- projectDataBase::isReadOnlySelect() rejects anything that isn't a
  single SELECT/WITH statement. Checked in newQuery() itself, the one
  choke point every query path already goes through -- including a
  query loaded from a saved nomenclature/summary table's <query>
  element on project open, not just the dialog's own custom-SQL box.
  ElementQueryWidget shows the same check live as you type, and
  BOMExportDialog surfaces it before running or exporting anything.
- BOMExportDialog gains a Preview button + table (QSqlQueryModel),
  so a report can be checked on screen before committing to a CSV file.
- ElementQueryWidget gains Importer.../Exporter... buttons that
  read/write nomenclature.json's saved reports as a JSON file, so a
  report can be handed to a colleague or another install. Import asks
  before overwriting a locally-saved report of the same name.

Verified: Qt 6.10.2, builds clean, ctest 8/8. Drove the real dialog
through Xvfb: typed "DROP TABLE element" into the custom-SQL box and
got the inline warning immediately, then confirmed Preview also
refuses it with a "Requête refusée" dialog rather than running it.
Preview against the real default query returned live column headers
and a row. Export opens a save dialog without crashing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-17 10:09:14 +12:00
parent c71839a291
commit 12ecf6b28f
8 changed files with 361 additions and 8 deletions
+82 -4
View File
@@ -28,6 +28,7 @@
#include "../qetproject.h"
#include <QLocale>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlDriver>
@@ -132,11 +133,88 @@ QETProject *projectDataBase::project() const
}
/**
@brief projectDataBase::newQuery
@return a QSqlquery with query as query
and the internal database of this class as database to use.
@brief projectDataBase::isReadOnlySelect
Every query that reaches newQuery() goes through this check first --
including one loaded from a saved nomenclature/summary table's <query>
element (ProjectDBModel::fromXml()), which makes this a defense against
a crafted project file, not just a careless custom-SQL edit
(qelectrotech-source-mirror#886 asked for read-only enforcement on the
custom SQL reports feature; this covers every path into newQuery(), not
just that one dialog).
Deliberately simple rather than a real SQL parser: reject more than one
statement (blocks stacking a write after a leading SELECT with `;`), and
require the query to start with SELECT or WITH. A determined attacker
with arbitrary SQL access to a local SQLite connection can still find
tricks a simple prefix check won't catch; this is meant to stop the
ordinary mistake and the obvious payload, not to be a security boundary
against a hostile file assumed to already run in some other trust
context.
@param query the raw SQL text to check
@param error set to a human-readable reason when this returns false
@return true if @p query looks like a single read-only SELECT/WITH
*/
QSqlQuery projectDataBase::newQuery(const QString &query) {
bool projectDataBase::isReadOnlySelect(const QString &query, QString *error)
{
if (error) {
error->clear();
}
QString trimmed = query.trimmed();
if (trimmed.endsWith(QLatin1Char(';'))) {
trimmed.chop(1);
trimmed = trimmed.trimmed();
}
if (trimmed.isEmpty()) {
if (error) {
*error = projectDataBase::tr("La requête est vide.");
}
return false;
}
if (trimmed.contains(QLatin1Char(';'))) {
if (error) {
*error = projectDataBase::tr("Une seule requête SELECT est autorisée"
" (le caractère ';' ne peut apparaître"
" qu'à la toute fin).");
}
return false;
}
const int first_space = trimmed.indexOf(QRegularExpression(QStringLiteral("\\s")));
const QString first_word = (first_space == -1 ? trimmed : trimmed.left(first_space)).toUpper();
if (first_word != QLatin1String("SELECT") && first_word != QLatin1String("WITH")) {
if (error) {
*error = projectDataBase::tr("Seules les requêtes en lecture seule"
" (SELECT ou WITH ... SELECT) sont"
" autorisées.");
}
return false;
}
return true;
}
/**
@brief projectDataBase::newQuery
@param query the SQL text to run -- must be a single read-only
SELECT/WITH statement, see isReadOnlySelect()
@param error set to a human-readable reason when the query was rejected
before ever reaching the database
@return a QSqlQuery with query as query and the internal database of
this class as database to use, or an unexecuted, harmless QSqlQuery if
the query was rejected
*/
QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
QString reason;
if (!isReadOnlySelect(query, &reason)) {
qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query;
if (error) {
*error = reason;
}
return QSqlQuery(m_data_base);
}
return QSqlQuery(query, m_data_base);
}