Author: Joe Ferguson (svpernova09)
Committer: Derick Rethans (derickr)
Date: 2026-08-18T17:16:45+01:00
Commit: Fix uncaught PDOException in manual lookup when sqlite is unavailable · php/web-php@65265f9 · GitHub
Raw diff: https://github.com/php/web-php/commit/65265f93924ad23582624825a8fd4c6b6ebba0b4.diff
Fix uncaught PDOException in manual lookup when sqlite is unavailable
Changed paths:
A tests/Unit/ManualLookup/FindManualPageTest.php
M include/manual-lookup.inc
Diff:
diff --git a/include/manual-lookup.inc b/include/manual-lookup.inc
index 4fbc3cd949..8d988041d1 100644
--- a/include/manual-lookup.inc
+++ b/include/manual-lookup.inc
@@ -112,7 +112,8 @@ function find_manual_page($lang, $keyword)
if (in_array('sqlite', PDO::getAvailableDrivers(), true)) {
if (file_exists(ProjectGlobals::getBackendRoot() . '/manual-lookup.sqlite')) {
try {
- $dbh = new PDO( 'sqlite:' . ProjectGlobals::getBackendRoot() . '/manual-lookup.sqlite', '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true] );
+ // Check prepare()/execute() for false to fall back to the slow search
+ $dbh = new PDO( 'sqlite:' . ProjectGlobals::getBackendRoot() . '/manual-lookup.sqlite', '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT] );
} catch (PDOException $e) {
return find_manual_page_slow($lang, $keyword);
}
@@ -211,7 +212,8 @@ function find_manual_page($lang, $keyword)
}
}
} else {
- error_noservice();
+ // prepare() failed, fall back to the slow search
+ return find_manual_page_slow($langs[0], $kw);
}
}
diff --git a/tests/Unit/ManualLookup/FindManualPageTest.php b/tests/Unit/ManualLookup/FindManualPageTest.php
new file mode 100644
index 0000000000..251e7ef88a
--- /dev/null
+++ b/tests/Unit/ManualLookup/FindManualPageTest.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace {
+ // include/manual-lookup.inc defines global functions and depends on the global
+ // get_manual_search_sections(). That lives in include/site.inc, which cannot be
+ // required in isolation, so repeat the list from site.inc here.
+ if (!function_exists('get_manual_search_sections')) {
+ /** @return list<string> */
+ function get_manual_search_sections(): array
+ {
+ return [
+ "", "book.", "ref.", "function.", "class.", "enum.",
+ "features.", "control-structures.", "language.",
+ "about.", "faq.",
+ ];
+ }
+ }
+
+ require_once phpweb\ProjectGlobals::getProjectRoot() . '/include/manual-lookup.inc';
+}
+
+namespace phpweb\Test\Unit\ManualLookup {
+
+ use phpweb\ProjectGlobals;
+ use PHPUnit\Framework;
+
+ #[Framework\Attributes\CoversFunction('find_manual_page')]
+ #[Framework\Attributes\CoversFunction('find_manual_page_slow')]
+ #[Framework\Attributes\RunTestsInSeparateProcesses]
+ #[Framework\Attributes\PreserveGlobalState(false)]
+ final class FindManualPageTest extends Framework\TestCase
+ {
+ // Manual pages checked into public/manual/en/ that the searches below resolve to.
+ private const SLOW_PATH_PAGE = '/manual/en/function.strpos.php';
+
+ private const FAST_PATH_PAGE = '/manual/en/function.rtrim.php';
+
+ private string $database;
+
+ protected function setUp(): void
+ {
+ $this->database = ProjectGlobals::getBackendRoot() . '/manual-lookup.sqlite';
+
+ // A database here means a live checkout with an rsynced manual, not a test one
+ if (file_exists($this->database)) {
+ self::markTestSkipped('manual-lookup.sqlite is present, refusing to overwrite it');
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ @unlink($this->database);
+ }
+
+ /**
+ * Regression test for the production fatal:
+ * Uncaught PDOException: SQLSTATE[HY000]: General error: 8
+ * attempt to write a readonly database in include/manual-lookup.inc
+ *
+ * A read-only, locked or truncated database must fall back to the slow
+ * search rather than throwing.
+ */
+ public function testFallsBackToSlowSearchWhenSqliteQueryFails(): void
+ {
+ file_put_contents($this->database, 'this is not a sqlite database');
+
+ self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 'strpos'));
+ }
+
+ public function testFallsBackToSlowSearchForDottedKeywordWhenSqliteQueryFails(): void
+ {
+ // A dotted keyword takes the other SQL branch, which must fall back too
+ file_put_contents($this->database, 'this is not a sqlite database');
+
+ self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 'function.strpos'));
+ }
+
+ public function testFallsBackToSlowSearchWhenNoDatabasePresent(): void
+ {
+ self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 'strpos'));
+ }
+
+ /**
+ * The fast path maps the keyword to a different page than the slow search
+ * would find, so a match on it proves the database was really used.
+ */
+ #[Framework\Attributes\RequiresPhpExtension('pdo_sqlite')]
+ public function testUsesSqliteFastPathWhenDatabaseIsValid(): void
+ {
+ $dbh = new \PDO('sqlite:' . $this->database);
+ $dbh->exec('CREATE TABLE fs (lang TEXT, prefix TEXT, keyword TEXT, name TEXT, prio INT)');
+ $dbh->exec(sprintf(
+ "INSERT INTO fs (lang, prefix, keyword, name, prio) VALUES ('en', 'function.', 'strpos', '%s', 3)",
+ self::FAST_PATH_PAGE,
+ ));
+ $dbh = null;
+
+ self::assertSame(self::FAST_PATH_PAGE, find_manual_page('en', 'strpos'));
+ }
+ }
+}