Summary
SqliteConnection.inferQueryColumns (lib/core/database/sqlite_connection.dart:316) is called by SqliteSqlWorkspace._runQuery (lib/features/sqlite/sqlite_sql_workspace.dart:385) whenever a read-only query returns zero rows, so the grid can still show column headers. It infers columns by running:
await _db!.execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql');
sqflite's Database.execute (unlike rawQuery) executes every statement in the string it's given, not just the first one. rawQuery silently stops after the first statement, which is why the empty-result branch is reached in the first place — that first statement produced 0 rows. But if the user's input contains more than one statement (e.g. SELECT * FROM t WHERE 0; DELETE FROM t), the first statement is what rawQuery executed and returned (0 rows), and the second statement gets executed as a side effect of building the TEMP VIEW probe, since execute doesn't stop at the first ;.
Repro (confirmed via a throwaway test against sqflite_common_ffi)
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath,
options: OpenDatabaseOptions(singleInstance: false));
await db.execute('CREATE TABLE t(x)');
await db.execute('INSERT INTO t VALUES (1),(2)');
await db.rawQuery('SELECT 1 WHERE 0; DELETE FROM t');
// table t still has 2 rows here (rawQuery ignored the second statement)
await db.execute('CREATE TEMP VIEW v AS SELECT 1 WHERE 0; DELETE FROM t');
// table t now has 0 rows: the DELETE ran as part of the "harmless" view probe
In the app: open a SQLite table with data, type SELECT * FROM t WHERE 0; DELETE FROM t in the SQL workspace, run it. The grid shows an empty result (expected), but the DELETE silently executes because the result was empty and column inference kicked in.
Why this slips past the destructive-SQL guard
DestructiveSqlDetector scans each statement in the raw SQL for confirmation, but the actual deletion here doesn't happen via the user's own execute path — it happens inside inferQueryColumns's TEMP VIEW probe, which runs unconditionally with no destructive-SQL check at all.
Suggested fix
- Refuse column inference (return
const []) when the statement text contains more than one statement (semicolon-separated, ignoring the trailing one and string/comment content).
- Or: parse/validate that
cleanSql is a single statement before ever passing it to execute.
Scope
Only writable (non-read-only) SQLite connections are affected, since a read-only connection would reject the DELETE outright. Found during a code-quality review of the dev branch (147-file diff since main) rather than reported by a user.
Summary
SqliteConnection.inferQueryColumns(lib/core/database/sqlite_connection.dart:316) is called bySqliteSqlWorkspace._runQuery(lib/features/sqlite/sqlite_sql_workspace.dart:385) whenever a read-only query returns zero rows, so the grid can still show column headers. It infers columns by running:sqflite'sDatabase.execute(unlikerawQuery) executes every statement in the string it's given, not just the first one.rawQuerysilently stops after the first statement, which is why the empty-result branch is reached in the first place — that first statement produced 0 rows. But if the user's input contains more than one statement (e.g.SELECT * FROM t WHERE 0; DELETE FROM t), the first statement is whatrawQueryexecuted and returned (0 rows), and the second statement gets executed as a side effect of building the TEMP VIEW probe, sinceexecutedoesn't stop at the first;.Repro (confirmed via a throwaway test against
sqflite_common_ffi)In the app: open a SQLite table with data, type
SELECT * FROM t WHERE 0; DELETE FROM tin the SQL workspace, run it. The grid shows an empty result (expected), but the DELETE silently executes because the result was empty and column inference kicked in.Why this slips past the destructive-SQL guard
DestructiveSqlDetectorscans each statement in the raw SQL for confirmation, but the actual deletion here doesn't happen via the user's own execute path — it happens insideinferQueryColumns's TEMP VIEW probe, which runs unconditionally with no destructive-SQL check at all.Suggested fix
const []) when the statement text contains more than one statement (semicolon-separated, ignoring the trailing one and string/comment content).cleanSqlis a single statement before ever passing it toexecute.Scope
Only writable (non-read-only) SQLite connections are affected, since a read-only connection would reject the DELETE outright. Found during a code-quality review of the
devbranch (147-file diff sincemain) rather than reported by a user.