Author: Joe Ferguson (svpernova09) Committer: Derick Rethans (derickr) Date: 2026-08-18T17:16:45+01:00
Commit: https://github.com/php/web-php/commit/65265f93924ad23582624825a8fd4c6b6ebba0b4 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')); + } + } +}
