From a820de6714b93f8a8a2a75c0bc679e8dfdc75801 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 21 Aug 2026 22:53:49 +0700 Subject: [PATCH] fix(plugin-sqlite): report a query that stops early instead of returning the rows it got --- CHANGELOG.md | 1 + Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 30 +++++++++++++-- .../SQLiteFirstTableLoadUITests.swift | 38 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 TableProUITests/SQLiteFirstTableLoadUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7283d2c02..7144350c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away. - Fixed a crash when an input method, dictation or Look Up asked the editor about text that had already been edited away. (#2339) - Fixed crashes when the editor's layout, syntax highlighting or accessibility read text that a newer edit had already removed. (#2340) +- A SQLite query that stops early, because the database is locked or the volume returns an error, now says so instead of showing an empty table. - The XLSX, MQL and SQL Import plugins linked to a documentation page that did not exist. They now point at Import & Export. ## [0.67.0] - 2026-08-21 diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3db48ee6e..c2de3ed32 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -173,7 +173,8 @@ private actor SQLiteConnectionActor { var rowsAffected = 0 var truncated = false - while sqlite3_step(statement) == SQLITE_ROW { + var stepResult = sqlite3_step(statement) + while stepResult == SQLITE_ROW { if rows.count >= PluginRowLimits.emergencyMax { truncated = true break @@ -200,6 +201,11 @@ private actor SQLiteConnectionActor { } rows.append(row) + stepResult = sqlite3_step(statement) + } + + if !truncated, stepResult != SQLITE_DONE { + throw SQLitePluginError.queryFailed(String(cString: sqlite3_errmsg(db))) } if columns.isEmpty { @@ -259,7 +265,8 @@ private actor SQLiteConnectionActor { var batch: [PluginRow] = [] batch.reserveCapacity(batchSize) - while sqlite3_step(statement) == SQLITE_ROW { + var stepResult = sqlite3_step(statement) + while stepResult == SQLITE_ROW { if Task.isCancelled { if !batch.isEmpty { continuation.yield(.rows(batch)) @@ -294,12 +301,23 @@ private actor SQLiteConnectionActor { continuation.yield(.rows(batch)) batch.removeAll(keepingCapacity: true) } + stepResult = sqlite3_step(statement) } if !batch.isEmpty { continuation.yield(.rows(batch)) } + // A step that ends on anything but `SQLITE_DONE` stopped early: a locked database, an I/O + // error on the volume, a corrupt page. Finishing the stream normally would report the rows + // read so far as the whole table, which is indistinguishable from an empty one. + guard stepResult == SQLITE_DONE else { + let message = String(cString: sqlite3_errmsg(db)) + sqlite3_finalize(statement) + continuation.finish(throwing: SQLitePluginError.queryFailed(message)) + return + } + sqlite3_finalize(statement) continuation.finish() } @@ -371,7 +389,8 @@ private actor SQLiteConnectionActor { var rowsAffected = 0 var truncated = false - while sqlite3_step(statement) == SQLITE_ROW { + var stepResult = sqlite3_step(statement) + while stepResult == SQLITE_ROW { if rows.count >= PluginRowLimits.emergencyMax { truncated = true break @@ -398,6 +417,11 @@ private actor SQLiteConnectionActor { } rows.append(row) + stepResult = sqlite3_step(statement) + } + + if !truncated, stepResult != SQLITE_DONE { + throw SQLitePluginError.queryFailed(String(cString: sqlite3_errmsg(db))) } if columns.isEmpty { diff --git a/TableProUITests/SQLiteFirstTableLoadUITests.swift b/TableProUITests/SQLiteFirstTableLoadUITests.swift new file mode 100644 index 000000000..9031aba18 --- /dev/null +++ b/TableProUITests/SQLiteFirstTableLoadUITests.swift @@ -0,0 +1,38 @@ +import XCTest + +/// Issue #2342. The first table clicked after opening a SQLite database showed "Executing…" +/// forever. Cancelling it and clicking any table afterwards, including the same one, loaded +/// immediately, and an empty table stalled exactly like a large one, so the stall was never the +/// query itself. +final class SQLiteFirstTableLoadUITests: UITestCase { + func testTheFirstTableClickAfterOpeningADatabaseLoadsItsRows() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 30)) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + + clickAtCenter(try tableRow(named: "Album", in: window)) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "The first table click must open the data grid") + XCTAssertTrue( + waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + "The first table click must load its rows rather than stall" + ) + XCTAssertTrue( + waitForPredicate(timeout: 15) { !window.staticTexts["Executing…"].exists }, + "The executing indicator must clear once the rows are in" + ) + } + + private func tableRow(named name: String, in window: XCUIElement) throws -> XCUIElement { + let match = window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Table: \(name)")) + .firstMatch + XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") + return match + } +}