Files
FreshRSS/app/Models/EntryDAOSQLite.php
Song ZhifeiandAlexandre Alapetite 148c140843 Fix marking filtered label articles as read in SQLite and PostgreSQL (#9264)
* Fix marking filtered label articles as read in SQLite

Remove the undefined entry alias from SQLite markReadTag filters. Add SQLite regression coverage for search and state filters, label boundaries, the maximum entry ID, and unread cache updates.

Fixes #9214

* Test label read filters across supported databases

Run the entry DAO regression cases against configurable SQLite, PostgreSQL, MySQL, or MariaDB connections using temporary tables. Match compressed content storage on MySQL/MariaDB, cover marking articles unread, and document how to run each backend.

* Keep only fix
Will rework test approach in future work

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
2026-09-06 17:44:14 +02:00

213 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
#[\Override]
public static function isCompressed(): bool {
return false;
}
#[\Override]
public static function hasNativeHex(): bool {
return false;
}
#[\Override]
protected static function sqlConcat(string $s1, string $s2): string {
return $s1 . '||' . $s2;
}
#[\Override]
public static function sqlHexDecode(string $x): string {
return $x;
}
#[\Override]
public static function sqlIgnoreConflict(string $sql): string {
return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
}
#[\Override]
protected static function sqlLimitAll(): string {
// https://sqlite.org/lang_select.html#the_limit_clause
return '-1';
}
#[\Override]
public static function sqlRandom(): string {
return 'RANDOM()';
}
#[\Override]
protected static function sqlRegex(string $expression, string $regex, array &$values): string {
$values[] = $regex;
return "{$expression} REGEXP ?";
}
#[\Override]
protected function registerSqlFunctions(string $sql): void {
if (!str_contains($sql, ' REGEXP ')) {
return;
}
// https://www.php.net/pdo.sqlitecreatefunction
// https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operators
$this->pdo->sqliteCreateFunction('regexp',
function (string $pattern, string $text): bool {
return preg_match($pattern, $text) === 1;
},
2
);
}
/** @param array{0:string,1:int,2:string} $errorInfo */
#[\Override]
protected function autoUpdateDb(array $errorInfo): bool {
$columns = $this->fetchColumn("PRAGMA table_info('entry')", 1);
if ($columns !== null) {
foreach (['attributes', 'lastUserModified', 'lastModified'] as $column) {
if (!in_array($column, $columns, true)) {
return $this->addColumn($column);
}
}
}
return false;
}
#[\Override]
public function commitNewEntries(): bool {
$sql = <<<'SQL'
DROP TABLE IF EXISTS `tmp`;
CREATE TEMP TABLE `tmp` AS
SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entrytmp`
ORDER BY date, id;
INSERT OR IGNORE INTO `_entry`
(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS id,
guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `tmp` t
ORDER BY t.date, t.id;
DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);
DROP TABLE IF EXISTS `tmp`;
SQL;
$hadTransaction = $this->pdo->inTransaction();
if (!$hadTransaction) {
$this->pdo->beginTransaction();
}
$result = $this->pdo->exec($sql) !== false;
if (!$result) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
}
if (!$hadTransaction) {
$this->pdo->commit();
}
return $result;
}
/**
* Toggle the read marker on one or more article.
* Then the cache is updated.
*
* @param numeric-string|array<numeric-string> $ids
* @return int|false affected rows
*/
#[\Override]
public function markRead(array|string $ids, bool $is_read = true): int|false {
if (is_array($ids)) { //Many IDs at once (used by API)
//if (true) { //Speed heuristics //TODO: Not implemented yet for SQLite (so always call IDs one by one)
$affected = 0;
foreach ($ids as $id) {
$affected += ($this->markRead($id, $is_read) ?: 0);
}
return $affected;
//}
} else {
FreshRSS_UserDAO::touch();
$this->pdo->beginTransaction();
$sql = <<<'SQL'
UPDATE `_entry` SET is_read=:is_read, `lastUserModified` = :last_user_modified
WHERE id=:id AND is_read=:previous_is_read
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm === false ||
!$stm->bindValue(':is_read', $is_read ? 1 : 0, PDO::PARAM_INT) ||
!$stm->bindValue(':last_user_modified', time(), PDO::PARAM_INT) ||
!$stm->bindValue(':id', $ids, PDO::PARAM_STR) || // TODO: Test PDO::PARAM_INT on 32-bit platform
!$stm->bindValue(':previous_is_read', $is_read ? 0 : 1, PDO::PARAM_INT) ||
!$stm->execute()) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
/** @var array{0:string,1:int,2:string} $info */
if ($this->autoUpdateDb($info)) {
return $this->markRead($ids, $is_read);
} else {
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
}
$affected = $stm->rowCount();
if ($affected > 0) {
$delta = $is_read ? '-1' : '+1';
$sql = <<<SQL
UPDATE `_feed` SET `cache_nbUnreads`=`cache_nbUnreads` {$delta}
WHERE id=(SELECT e.id_feed FROM `_entry` e WHERE e.id=:id)
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm === false ||
!$stm->bindValue(':id', $ids, PDO::PARAM_STR) ||
!$stm->execute()) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
}
$this->pdo->commit();
if ($affected > 0) {
Minz_ExtensionManager::callHook(Minz_HookType::EntriesRead, [$ids], $is_read);
}
return $affected;
}
}
/**
* Mark all the articles in a tag as read.
* @param int $id tag ID, or empty for targeting any tag
* @param string $idMax max article ID
* @return int|false affected rows
*/
#[\Override]
public function markReadTag(int $id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false {
FreshRSS_UserDAO::touch();
if ($idMax == 0) {
$idMax = uTimeString();
Minz_Log::debug('Calling markReadTag(0) is deprecated!');
}
$tagCondition = $id == 0 ? '' : 'WHERE et.id_tag = ?';
$sql = <<<SQL
UPDATE `_entry` SET is_read = ?, `lastUserModified` = ? WHERE is_read <> ? AND id <= ?
AND id IN (SELECT et.id_entry FROM `_entrytag` et {$tagCondition})
SQL;
$values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0, $idMax];
if ($id != 0) {
$values[] = $id;
}
[$searchValues, $search] = $this->sqlListEntriesWhere(alias: '', state: $state, filters: $filters);
$stm = $this->pdo->prepare($sql . $search);
if ($stm === false || !$stm->execute(array_merge($values, $searchValues))) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
return $affected;
}
}