diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 23bd0e94e..0dc55ea4e 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -34,7 +34,6 @@ #include #include #include -#include @@ -205,10 +204,9 @@ bool projectDataBase::isReadOnlySelect(const QString &query, QString *error) @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 + or failed + @return the executed query, on the internal database of this class, or + an empty, harmless QSqlQuery if the query was rejected or failed */ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) { QString reason; @@ -226,24 +224,24 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) { return QSqlQuery(m_data_base); } - // Second gate, and the one that actually enforces read-only: SQLite is - // asked about the statement it compiled, instead of the text being read - // for clues. The first gate cannot see through a CTE prefix -- - // "WITH x AS (SELECT 1) DELETE FROM element" starts with WITH, contains - // no semicolon, and deletes every row. That matters beyond the - // custom-query box, because this path is reachable from a file: a - // 's saved is read straight out of the .qet by - // ProjectDBModel::fromXml() and executed by fillValue(), so opening or - // exporting a project someone else produced would have been enough. - if (!QETSql::isSingleReadOnlyStatement(sqliteHandle(&m_data_base), query, &reason)) { + // Second gate, and the one that actually enforces read-only: SQLite + // runs the statement with query_only set and refuses a write itself, + // instead of the text being read for clues. The first gate cannot see + // through a CTE prefix -- "WITH x AS (SELECT 1) DELETE FROM element" + // starts with WITH, contains no semicolon, and deletes every row. That + // matters beyond the custom-query box, because this path is reachable + // from a file: a 's saved is read straight out + // of the .qet by ProjectDBModel::fromXml() and executed by fillValue(), + // so opening or exporting a project someone else produced would have + // been enough. + QSqlQuery result = QETSql::execReadOnly(m_data_base, query, &reason); + if (!reason.isEmpty()) { qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query; if (error) { *error = reason; } - return QSqlQuery(m_data_base); } - - return QSqlQuery(query, m_data_base); + return result; } /** @@ -1301,23 +1299,6 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram) } } -/** - @brief projectDataBase::sqliteHandle - @param db - @return the sqlite3 handler class used internally by db -*/ -sqlite3 *projectDataBase::sqliteHandle(QSqlDatabase *db) -{ - sqlite3 *handle = nullptr; - - QVariant v = db->driver()->handle(); - if (v.isValid() && qstrcmp(v.typeName(), "sqlite3*") == 0) { - handle = *static_cast(v.data()); - } - - return handle; -} - #ifdef QET_EXPORT_PROJECT_DB /** diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index ff65da494..6a18b09a0 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -29,7 +29,6 @@ class QETProject; class Diagram; class Conductor; class Terminal; -struct sqlite3; /** @brief The projectDataBase class @@ -156,12 +155,6 @@ class projectDataBase : public QObject m_cascade_remove_conductor_query, m_cascade_remove_element_query; - public: - // Deliberately outside the QET_EXPORT_PROJECT_DB guard below: - // newQuery() needs the raw connection to ask SQLite whether a - // query only reads, and that check runs in every build. - static sqlite3 *sqliteHandle(QSqlDatabase *db); - #ifdef QET_EXPORT_PROJECT_DB public: static void exportDb(projectDataBase *db, diff --git a/sources/dataBase/sqlreadonly.cpp b/sources/dataBase/sqlreadonly.cpp index 48cc19ccc..95996bde5 100644 --- a/sources/dataBase/sqlreadonly.cpp +++ b/sources/dataBase/sqlreadonly.cpp @@ -18,15 +18,14 @@ #include "sqlreadonly.h" #include - -#include +#include namespace QETSql { /** - @brief QETSql::isSingleReadOnlyStatement - Ask SQLite itself whether @p query is exactly one statement, and whether - that statement only reads. + @brief QETSql::execReadOnly + Run @p query on @p db with SQLite's query_only pragma set, so that + SQLite itself refuses anything that would write. Why SQLite is asked rather than the text inspected: a check on the query's first keyword cannot see what the statement actually does. @@ -37,98 +36,75 @@ namespace QETSql { WITH x AS (SELECT 1) DELETE FROM element @endcode - begins with WITH, contains no semicolon, and deletes every row. - sqlite3_stmt_readonly() reports on the statement SQLite compiled, not - on how it was spelled, so the same query is correctly refused here - while an ordinary WITH ... SELECT still passes. + begins with WITH, contains no semicolon, and deletes every row. With + query_only set, SQLite fails that statement with SQLITE_READONLY when it + tries to start writing, before any row is touched, while an ordinary + WITH ... SELECT still runs. - The statement is compiled and immediately finalised; sqlite3_prepare_v2() - does not run it, so nothing is executed to reach this verdict. + Why a pragma rather than sqlite3_stmt_readonly(): that needs the + driver's native sqlite3 handle passed to the libsqlite3 QElectroTech + links. The QSQLITE plugin of the Qt online installer carries its own + private copy of SQLite, so that handle belongs to another library and + the call crashes (qelectrotech-source-mirror#1045). A pragma goes + through the driver, whichever SQLite it uses. - This is a read-only test, NOT a statement-type allowlist: SQLite - considers ATTACH, BEGIN and several PRAGMAs read-only too, because none - of them change the contents of the database. Callers that need to - restrict which *kind* of statement is acceptable must say so separately - -- projectDataBase::newQuery() keeps isReadOnlySelect() in front of this + A statement that succeeded here is read-only, so running the returned + query again, as several callers do, runs a read-only statement again. + A refused one comes back as an empty query with nothing to run again: + query_only is only set for the duration of this call. + + Qt's SQLite driver refuses a second statement after the first one, so + "SELECT 1; DROP TABLE element" is refused too. + + This is a read-only test, NOT a statement-type allowlist: query_only + does not refuse ATTACH, BEGIN or most PRAGMAs, because none of them + change the contents of the database. Callers that need to restrict + which *kind* of statement is acceptable must say so separately -- + projectDataBase::newQuery() keeps isReadOnlySelect() in front of this for exactly that reason. - @param handle the connection the query would run on. A null handle is - refused rather than waved through: without it there is nothing to ask, - and guessing from the text is the weakness this exists to replace. + @param db the connection to run the query on @param query the raw SQL text - @param error set to a human-readable reason when this returns false - @return true if @p query is a single, read-only statement + @param error set to a human-readable reason when the query is refused + @return the executed query, or an empty query on @p db if @p query was + refused or failed */ -bool isSingleReadOnlyStatement(sqlite3 *handle, const QString &query, QString *error) +QSqlQuery execReadOnly(const QSqlDatabase &db, const QString &query, QString *error) { if (error) { error->clear(); } - if (!handle) { + if (!QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = ON"))) { if (error) { *error = QCoreApplication::translate("QETSql", "Impossible de vérifier la requête : " - "aucune connexion SQLite disponible."); + "la base de données ne peut pas être mise en lecture seule."); } - return false; + return QSqlQuery(db); } - const QByteArray utf8 = query.toUtf8(); - sqlite3_stmt *statement = nullptr; - const char *tail = nullptr; + QSqlQuery result(db); + const bool ok = result.exec(query); + QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = OFF")); - if (sqlite3_prepare_v2(handle, utf8.constData(), utf8.size(), - &statement, &tail) != SQLITE_OK) - { - if (error) { - *error = QCoreApplication::translate("QETSql", - "Requête SQL invalide : %1") - .arg(QString::fromUtf8(sqlite3_errmsg(handle))); - } - sqlite3_finalize(statement); - return false; + if (ok) { + return result; } - // Whitespace or a bare comment compiles successfully to no statement - // at all, and sqlite3_stmt_readonly() must not be handed that. - if (!statement) { - if (error) { - *error = QCoreApplication::translate("QETSql", - "La requête ne contient aucune instruction."); - } - return false; - } - - const bool read_only = sqlite3_stmt_readonly(statement) != 0; - sqlite3_finalize(statement); - - if (!read_only) { - if (error) { + if (error) { + // SQLITE_READONLY is 8; extended codes keep it in the low byte. + if ((result.lastError().nativeErrorCode().toInt() & 0xff) == 8) { *error = QCoreApplication::translate("QETSql", "Seules les requêtes en lecture seule sont autorisées : " "cette requête modifierait la base de données."); - } - return false; - } - - // tail points just past the first statement, semicolon included. - // Anything left once semicolons and spacing are stripped is a second - // statement -- caught structurally here, where "SELECT ';'" is a - // perfectly ordinary query rather than a suspicious string. - if (tail) { - QString rest = QString::fromUtf8(tail); - rest.remove(QLatin1Char(';')); - if (!rest.trimmed().isEmpty()) { - if (error) { - *error = QCoreApplication::translate("QETSql", - "Une seule requête est autorisée."); - } - return false; + } else { + *error = QCoreApplication::translate("QETSql", + "Requête SQL invalide : %1") + .arg(result.lastError().databaseText()); } } - - return true; + return QSqlQuery(db); } } // namespace QETSql diff --git a/sources/dataBase/sqlreadonly.h b/sources/dataBase/sqlreadonly.h index 6489e8bc9..a19e4666e 100644 --- a/sources/dataBase/sqlreadonly.h +++ b/sources/dataBase/sqlreadonly.h @@ -18,26 +18,26 @@ #ifndef SQLREADONLY_H #define SQLREADONLY_H +#include +#include #include -struct sqlite3; - /** - Deciding whether a piece of SQL only reads. + Running a piece of SQL only if it reads. - Deliberately its own translation unit, depending on nothing but QString - and SQLite: it is the enforcement point for every query QElectroTech - runs against a project database, including queries that arrive from - outside the application (a .qet file's saved report/table query), so it - is worth being able to test it in isolation -- see + Deliberately its own translation unit, depending on nothing but Qt SQL: + it is the enforcement point for every query QElectroTech runs against a + project database, including queries that arrive from outside the + application (a .qet file's saved report/table query), so it is worth + being able to test it in isolation -- see tests/qttest/tst_sqlreadonly.cpp, which links this file and nothing else of QElectroTech. */ namespace QETSql { - bool isSingleReadOnlyStatement(sqlite3 *handle, - const QString &query, - QString *error = nullptr); + QSqlQuery execReadOnly(const QSqlDatabase &db, + const QString &query, + QString *error = nullptr); } #endif // SQLREADONLY_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3296bc016..9ef65413b 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -156,21 +156,17 @@ add_test(NAME tst_qetstrings COMMAND tst_qetstrings) target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) -# QETSql::isSingleReadOnlyStatement() -- read-only enforcement for every -# project-database query, including the ones a .qet file carries. Compiles -# sqlreadonly.cpp alone against its own in-memory SQLite, so the security +# QETSql::execReadOnly() -- read-only enforcement for every project-database +# query, including the ones a .qet file carries. Compiles sqlreadonly.cpp +# alone against its own in-memory QSQLITE connection, so the security # property is checked without standing up a QETProject. -find_package(SQLite3 REQUIRED) -if(NOT TARGET SQLite3::SQLite3 AND TARGET SQLite::SQLite3) - add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3) -endif() add_executable( tst_sqlreadonly tst_sqlreadonly.cpp ${QET_DIR}/sources/dataBase/sqlreadonly.cpp) add_test(NAME tst_sqlreadonly COMMAND tst_sqlreadonly) target_include_directories(tst_sqlreadonly PRIVATE ${QET_DIR}/sources) -target_link_libraries(tst_sqlreadonly PRIVATE Qt::Test SQLite3::SQLite3) +target_link_libraries(tst_sqlreadonly PRIVATE Qt::Test Qt::Sql) # QetSettings::scriptingEnabled() -- whether QElectroTech may run a script. # Compiles qetsettings.cpp alone: the setting is deliberately a plain diff --git a/tests/qttest/tst_sqlreadonly.cpp b/tests/qttest/tst_sqlreadonly.cpp index d6c1f5882..a96735fd9 100644 --- a/tests/qttest/tst_sqlreadonly.cpp +++ b/tests/qttest/tst_sqlreadonly.cpp @@ -17,7 +17,7 @@ */ /* - QETSql::isSingleReadOnlyStatement() -- the read-only enforcement every + QETSql::execReadOnly() -- the read-only enforcement every project-database query goes through. The case that matters most here is the CTE prefix. SQLite has allowed @@ -27,6 +27,10 @@ is stored in the .qet and executed on load, so the text can arrive from a file rather than from the person at the keyboard. + The connection is a QSQLITE one, as in QElectroTech, so this runs + through whichever SQLite the Qt driver carries -- the point of #1045, + where the previous check crashed because it did not. + This test owns its own in-memory database and links nothing of QElectroTech but sqlreadonly.cpp, so it stays a fast, hermetic check of the security property itself. @@ -35,8 +39,8 @@ #include "dataBase/sqlreadonly.h" #include - -#include +#include +#include class TstSqlReadOnly : public QObject { @@ -55,58 +59,85 @@ class TstSqlReadOnly : public QObject void refusesTrailingStatement(); void refusesEmptyAndCommentOnly_data(); void refusesEmptyAndCommentOnly(); - void refusesWithoutAConnection(); void reportsAReason(); void doesNotExecuteWhatItRefuses(); + void refusedQueryCannotBeRunAgain(); + void acceptedQueryRunAgainStillReads(); + void leavesTheConnectionWritable(); private: - sqlite3 *m_db = nullptr; + QSqlDatabase m_db; int rowCount(); + bool isAccepted(const QString &query, QString *error = nullptr); }; int TstSqlReadOnly::rowCount() { - sqlite3_stmt *st = nullptr; - sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM element", -1, &st, nullptr); - sqlite3_step(st); - const int n = sqlite3_column_int(st, 0); - sqlite3_finalize(st); - return n; + QSqlQuery q(m_db); + q.exec(QStringLiteral("SELECT COUNT(*) FROM element")); + q.next(); + return q.value(0).toInt(); +} + +bool TstSqlReadOnly::isAccepted(const QString &query, QString *error) +{ + QString reason; + const QSqlQuery q = QETSql::execReadOnly(m_db, query, &reason); + if (error) { + *error = reason; + } + // Accepted means both: no reason given, and a query that actually ran. + // A refusal must be both too, or a caller could act on either half. + const bool accepted = reason.isEmpty(); + if (accepted != q.isActive()) { + qWarning() << "reason and query state disagree for" << query + << reason << q.isActive(); + return !accepted; // fails whichever way the test expected + } + return accepted; } void TstSqlReadOnly::initTestCase() { - QCOMPARE(sqlite3_open(":memory:", &m_db), SQLITE_OK); - QCOMPARE(sqlite3_exec(m_db, - "CREATE TABLE element (uuid TEXT);" - "INSERT INTO element VALUES ('a'),('b');", nullptr, nullptr, nullptr), - SQLITE_OK); + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), + QStringLiteral("tst_sqlreadonly")); + QVERIFY(m_db.open()); + QSqlQuery q(m_db); + QVERIFY(q.exec(QStringLiteral("CREATE TABLE element (uuid TEXT)"))); + QVERIFY(q.exec(QStringLiteral("INSERT INTO element VALUES ('a'),('b')"))); QCOMPARE(rowCount(), 2); } void TstSqlReadOnly::cleanupTestCase() { - sqlite3_close(m_db); - m_db = nullptr; + m_db.close(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(QStringLiteral("tst_sqlreadonly")); } void TstSqlReadOnly::acceptsOrdinaryReads() { - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element")); - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT uuid FROM element WHERE uuid = 'a'")); - // A semicolon inside a string literal is not a second statement. The - // textual check this replaced rejected exactly this. - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT ';' AS semicolon")); + QVERIFY(isAccepted("SELECT * FROM element")); + QVERIFY(isAccepted("SELECT uuid FROM element WHERE uuid = 'a'")); + // A semicolon inside a string literal is not a second statement. + QVERIFY(isAccepted("SELECT ';' AS semicolon")); // One trailing semicolon is ordinary punctuation, not a second statement. - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element;")); + QVERIFY(isAccepted("SELECT * FROM element;")); + + // And the rows come back: the returned query is the one that ran. + QSqlQuery q = QETSql::execReadOnly(m_db, "SELECT uuid FROM element ORDER BY uuid"); + QStringList uuids; + while (q.next()) { + uuids << q.value(0).toString(); + } + QCOMPARE(uuids, QStringList({"a", "b"})); } void TstSqlReadOnly::acceptsLegitimateCommonTableExpression() { // WITH must keep working -- the fix is not "ban CTEs". - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, - "WITH x AS (SELECT 1 AS n) SELECT n FROM x")); - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, + QVERIFY(isAccepted("WITH x AS (SELECT 1 AS n) SELECT n FROM x")); + QVERIFY(isAccepted( "WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) " "SELECT n FROM c LIMIT 3")); } @@ -117,13 +148,15 @@ void TstSqlReadOnly::refusesCtePrefixedWrites_data() QTest::newRow("delete") << "WITH x AS (SELECT 1) DELETE FROM element"; QTest::newRow("update") << "WITH x AS (SELECT 1) UPDATE element SET uuid = 'pwned'"; QTest::newRow("insert") << "WITH x AS (SELECT 1) INSERT INTO element VALUES ('injected')"; + QTest::newRow("returning") << "WITH x AS (SELECT 1) DELETE FROM element RETURNING uuid"; } void TstSqlReadOnly::refusesCtePrefixedWrites() { QFETCH(QString, query); - QVERIFY2(!QETSql::isSingleReadOnlyStatement(m_db, query), + QVERIFY2(!isAccepted(query), qPrintable(QStringLiteral("accepted a write: %1").arg(query))); + QCOMPARE(rowCount(), 2); } void TstSqlReadOnly::refusesBareWrites_data() @@ -133,18 +166,23 @@ void TstSqlReadOnly::refusesBareWrites_data() QTest::newRow("update") << "UPDATE element SET uuid = 'pwned'"; QTest::newRow("insert") << "INSERT INTO element VALUES ('injected')"; QTest::newRow("drop") << "DROP TABLE element"; + QTest::newRow("create") << "CREATE TABLE injected (x)"; + QTest::newRow("temp") << "CREATE TEMP TABLE injected (x)"; } void TstSqlReadOnly::refusesBareWrites() { QFETCH(QString, query); - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query)); + QVERIFY2(!isAccepted(query), + qPrintable(QStringLiteral("accepted a write: %1").arg(query))); + QCOMPARE(rowCount(), 2); } void TstSqlReadOnly::refusesTrailingStatement() { - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; DROP TABLE element")); - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "SELECT 1; SELECT 2")); + QVERIFY(!isAccepted("SELECT 1; DROP TABLE element")); + QVERIFY(!isAccepted("SELECT 1; SELECT 2")); + QCOMPARE(rowCount(), 2); } void TstSqlReadOnly::refusesEmptyAndCommentOnly_data() @@ -157,42 +195,71 @@ void TstSqlReadOnly::refusesEmptyAndCommentOnly_data() void TstSqlReadOnly::refusesEmptyAndCommentOnly() { - // sqlite3_prepare_v2() reports success and a null statement for these; - // sqlite3_stmt_readonly() must never be handed that. QFETCH(QString, query); - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, query)); -} - -void TstSqlReadOnly::refusesWithoutAConnection() -{ - // Fails closed: with no connection there is nothing to ask, and - // guessing from the text is the weakness this replaced. - QVERIFY(!QETSql::isSingleReadOnlyStatement(nullptr, "SELECT * FROM element")); + QVERIFY(!isAccepted(query)); } void TstSqlReadOnly::reportsAReason() { QString reason; - QVERIFY(!QETSql::isSingleReadOnlyStatement( - m_db, "WITH x AS (SELECT 1) DELETE FROM element", &reason)); + QVERIFY(!isAccepted("WITH x AS (SELECT 1) DELETE FROM element", &reason)); QVERIFY2(!reason.isEmpty(), "a refusal must say why"); reason = QStringLiteral("stale"); - QVERIFY(QETSql::isSingleReadOnlyStatement(m_db, "SELECT * FROM element", &reason)); + QVERIFY(isAccepted("SELECT * FROM element", &reason)); QVERIFY2(reason.isEmpty(), "an accepted query must not leave a reason behind"); } void TstSqlReadOnly::doesNotExecuteWhatItRefuses() { - // The check compiles the statement to inspect it. Proving the table is - // untouched afterwards is what says it compiled without running it -- - // and this same assertion goes red if the refusals above ever stop - // refusing, since then the caller would run the DELETE for real. + // Proving the table is untouched afterwards is what says the write was + // refused rather than run -- and this same assertion goes red if the + // refusals above ever stop refusing. QCOMPARE(rowCount(), 2); - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "WITH x AS (SELECT 1) DELETE FROM element")); - QVERIFY(!QETSql::isSingleReadOnlyStatement(m_db, "DELETE FROM element")); + QVERIFY(!isAccepted("WITH x AS (SELECT 1) DELETE FROM element")); + QVERIFY(!isAccepted("DELETE FROM element")); QCOMPARE(rowCount(), 2); } -QTEST_APPLESS_MAIN(TstSqlReadOnly) +void TstSqlReadOnly::refusedQueryCannotBeRunAgain() +{ + // Several callers of projectDataBase::newQuery() call exec() again on + // what it returns, and query_only is off by then. A refused query + // must therefore come back with nothing left to run. + QSqlQuery q = QETSql::execReadOnly(m_db, "WITH x AS (SELECT 1) DELETE FROM element"); + QVERIFY(!q.exec()); + QCOMPARE(rowCount(), 2); +} + +void TstSqlReadOnly::acceptedQueryRunAgainStillReads() +{ + QSqlQuery q = QETSql::execReadOnly(m_db, "SELECT uuid FROM element"); + QVERIFY(q.exec()); + int n = 0; + while (q.next()) { + ++n; + } + QCOMPARE(n, 2); +} + +void TstSqlReadOnly::leavesTheConnectionWritable() +{ + // query_only must not outlive the call, whatever its outcome: the + // project database is rebuilt by writes on this same connection. + QETSql::execReadOnly(m_db, "SELECT * FROM element"); + QETSql::execReadOnly(m_db, "DELETE FROM element"); + QETSql::execReadOnly(m_db, "not even sql"); + + QSqlQuery q(m_db); + QVERIFY(q.exec(QStringLiteral("PRAGMA query_only"))); + QVERIFY(q.next()); + QCOMPARE(q.value(0).toInt(), 0); + + QVERIFY(q.exec(QStringLiteral("INSERT INTO element VALUES ('c')"))); + QCOMPARE(rowCount(), 3); + QVERIFY(q.exec(QStringLiteral("DELETE FROM element WHERE uuid = 'c'"))); + QCOMPARE(rowCount(), 2); +} + +QTEST_GUILESS_MAIN(TstSqlReadOnly) #include "tst_sqlreadonly.moc"