From 46c35d2297cba8f0f08fcbb432337cc9c03af0f1 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 21 Sep 2026 19:14:53 +1200 Subject: [PATCH] Let a script query the project database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every structural question this API could answer, it answered by walking live objects. The project builds a SQLite database that already knows most of them, and nothing outside the application could reach it. tables() what is queryable, tables and views query(sql) rows, one object per row queryError() why the last one returned nothing This is not a new door. QElectroTech already ships a "Requête SQL personnalisée" box in the element-query dialog where a user types arbitrary SQL, guarded by projectDataBase::isReadOnlySelect(); query() goes through projectDataBase::newQuery(), which applies that same rule and returns the same rejection message. A script gets what a user already has, and neither can write: DELETE, UPDATE and a chained "SELECT 1; DROP TABLE" are all refused before reaching SQLite. An empty result and a failure are told apart. query() returns no rows for both, so queryError() carries the reason -- a refusal, or SQLite's own message for a bad column -- and is empty when the query simply matched nothing. Conflating those is how a silent typo in a column name becomes "there are no such elements". No updateDB() before querying, and that is a measured decision rather than an omission. A script that has just edited something is the expected caller, so a stale cache was the obvious hazard; but projectDataBase maintains itself incrementally through addElement(), elementInfoChanged(), addConductor() and the rest, which the undo commands behind every edit already call. Tested both ways on the cases most likely to go stale -- an element added and labelled, a conductor property changed -- each queried immediately afterwards through both the table and the view. Identical counts with the rebuild and without it, and updateDB() repopulates every table, so calling it per query would have been real cost for no benefit. The comment says so, so it is not added back on the assumption it must be needed. The views are the surface to depend on: element_nomenclature_view, project_summary_view and wiring_list_view exist to be queried. The tables are how the cache is arranged today and a column may move -- which is why tables() lists both and the header says which is which. Verified against examples/industrial.qet, the largest shipped project: 618 elements counted, the busiest wire numbers ranked (0VDC 93 times, 24V2 64), and duplicate element labels found by GROUP BY ... HAVING -- V6 seven times, V5 six -- which is a design-rule question no tool here could previously ask. Qt 6.10.2, ctest matches master. Co-Authored-By: Claude Opus 5 (1M context) --- sources/scripting/qetscriptapi.cpp | 93 ++++++++++++++++++++++++++++++ sources/scripting/qetscriptapi.h | 23 ++++++++ 2 files changed, 116 insertions(+) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 747176a59..c8f30b417 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -27,6 +27,7 @@ #include "../qet.h" #include "../qetgraphicsitem/element.h" #include "../qetmessagebox.h" +#include "../dataBase/projectdatabase.h" #include "../qetproject.h" #include "../qetresult.h" #include "../qetgraphicsitem/conductor.h" @@ -42,6 +43,9 @@ #include "../undocommand/linkelementcommand.h" #include "../utils/conductorcreator.h" +#include +#include +#include #include #include @@ -1164,6 +1168,95 @@ bool QetScriptApi::deleteShape(int folioIndex, int shapeIndex) return true; } +/** + @brief QetScriptApi::tables + The tables and views the project database holds, as "name (type)". + + Worth reading before writing a query against them: the three *_view + entries are the queryable surface and are named for it; the tables are + how the cache is arranged today. +*/ +QStringList QetScriptApi::tables() const +{ + QStringList list; + if (!m_project || !m_project->dataBase()) return list; + + QSqlQuery q = m_project->dataBase()->newQuery(QStringLiteral( + "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " + "ORDER BY type, name")); + while (q.next()) { + list << QStringLiteral("%1 (%2)").arg(q.value(0).toString(), q.value(1).toString()); + } + return list; +} + +/** + @brief QetScriptApi::query + Run a read-only SELECT against the project database and return its rows + as objects, one property per column. + + Goes through projectDataBase::newQuery(), which applies + isReadOnlySelect() itself -- the same rule, and the same rejection + message, that the "Requête SQL personnalisée" box in the element-query + dialog shows a user. Nothing here can write: a statement that is not a + single SELECT or WITH...SELECT is refused before it reaches SQLite. + + No updateDB() first, deliberately. A script that has just edited + something is the expected caller, so querying a stale cache was the + obvious hazard -- but projectDataBase maintains itself incrementally + through addElement()/elementInfoChanged()/addConductor() and the rest, + which the undo commands behind every edit here already call. Tested + both ways on the cases most likely to be stale: an element added and + labelled, and a conductor property changed, each queried immediately + afterwards through both the table and the view. The counts are the + same with the rebuild and without it. Since updateDB() is a full + repopulation of every table, calling it per query would have been a + real cost for no observable benefit -- so it is not called, and this + note exists so it is not added back on the assumption that it must be + needed. + + @return the rows; empty on refusal or SQL error, with queryError() + saying which. An empty result and a failure are not the same thing. +*/ +QVariantList QetScriptApi::query(const QString &sql) +{ + m_query_error.clear(); + QVariantList rows; + if (!m_project || !m_project->dataBase()) { + m_query_error = QStringLiteral("no project database"); + return rows; + } + + QString rejection; + QSqlQuery q = m_project->dataBase()->newQuery(sql, &rejection); + if (!rejection.isEmpty()) { + m_query_error = rejection; + log(QStringLiteral("qet.query: %1").arg(rejection)); + return rows; + } + if (q.lastError().isValid()) { + m_query_error = q.lastError().text(); + log(QStringLiteral("qet.query: %1").arg(m_query_error)); + return rows; + } + + const QSqlRecord record = q.record(); + while (q.next()) + { + QVariantMap row; + for (int i = 0 ; i < record.count() ; ++i) { + row.insert(record.fieldName(i), q.value(i)); + } + rows << row; + } + return rows; +} + +QString QetScriptApi::queryError() const +{ + return m_query_error; +} + int QetScriptApi::addFolio() { if (!m_project) return -1; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 816cbb9ad..2759b7636 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -21,6 +21,7 @@ #include #include #include +#include class QETProject; class DiagramView; @@ -133,6 +134,22 @@ class QetShapeItem; deleting one: indexes after the affected position shift, the way a list's do. Call texts() or shapes() again rather than holding an index across an edit that adds or removes one. + - @b Querying the project database: run a read-only SELECT against the + SQLite database QElectroTech builds from the project, and get rows + back as objects. This is not a new door. QET already ships a + "Requête SQL personnalisée" box in the element-query dialog where a + user types arbitrary SQL, and it is guarded by the same + projectDataBase::isReadOnlySelect() this calls through + projectDataBase::newQuery(). A script gets what a user already has, + under the same rule, and neither can write. + + What is worth knowing is what the database @b is: a cache, rebuilt + from the XML on every load and never written to disk. The three + views -- element_nomenclature_view, project_summary_view and + wiring_list_view -- exist to be queried and are the surface to + depend on. The underlying tables are how the cache happens to be + arranged today, and a column may move. tables() lists both so a + script can see what it is querying rather than guess. - @b Navigating and @b messaging: select an element, zoom the active view, and show the user a message. Deliberately narrow: selection and messaging work with no view at all (headless `--run`); zoom is a no-op @@ -241,6 +258,11 @@ class QetScriptApi : public QObject double x1, double y1, double x2, double y2); Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex); + // -- query the project database -- + Q_INVOKABLE QStringList tables() const; + Q_INVOKABLE QVariantList query(const QString &sql); + Q_INVOKABLE QString queryError() const; + // -- folios -- Q_INVOKABLE int addFolio(); Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title); @@ -276,6 +298,7 @@ class QetScriptApi : public QObject QETProject *m_project; DiagramView *m_view; + QString m_query_error; }; #endif // QET_SCRIPT_API_H