Sfoglia il codice sorgente

SQL improve PHP syntax uniformity (#8604)

* New SQL wrapper function `fetchInt()`
* Favour use of `fetchAssoc()`, `fetchInt()`, `fetchColumn()`
* Favour Nowdoc / Heredoc syntax for SQL
    * Update indenting to PHP 8.1+ convention
* Favour `bindValue()` instead of position `?` when possible
* Favour `bindValue()` over `bindParam()`
* More uniform and robust syntax when using `bindValue()`, checking return code
Alexandre Alapetite 2 settimane fa
parent
commit
aeb55693e4

+ 90 - 76
app/Models/CategoryDAO.php

@@ -13,11 +13,10 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	public function resetDefaultCategoryName(): bool {
 		//FreshRSS 1.15.1
 		$stm = $this->pdo->prepare('UPDATE `_category` SET name = :name WHERE id = :id');
-		if ($stm !== false) {
-			$stm->bindValue(':id', self::DEFAULTCATEGORYID, PDO::PARAM_INT);
-			$stm->bindValue(':name', self::DEFAULT_CATEGORY_NAME);
-		}
-		return $stm !== false && $stm->execute();
+		return $stm !== false &&
+			$stm->bindValue(':id', self::DEFAULTCATEGORYID, PDO::PARAM_INT) &&
+			$stm->bindValue(':name', self::DEFAULT_CATEGORY_NAME) &&
+			$stm->execute();
 	}
 
 	protected function addColumn(string $name): bool {
@@ -117,37 +116,34 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	public function addCategory(array $valuesTmp): int|false {
 		if (empty($valuesTmp['id'])) {	// Auto-generated ID
 			$sql = <<<'SQL'
-INSERT INTO `_category`(name, kind, attributes)
-SELECT * FROM (SELECT :name1 AS name, 1*:kind AS kind, :attributes AS attributes) c2
-SQL;
+				INSERT INTO `_category`(name, kind, attributes)
+				SELECT * FROM (SELECT :name1 AS name, 1*:kind AS kind, :attributes AS attributes) c2
+				SQL;
 		} else {
 			$sql = <<<'SQL'
-INSERT INTO `_category`(id, name, kind, attributes)
-SELECT * FROM (SELECT 1*:id AS id, :name1 AS name, 1*:kind AS kind, :attributes AS attributes) c2
-SQL;
+				INSERT INTO `_category`(id, name, kind, attributes)
+				SELECT * FROM (SELECT 1*:id AS id, :name1 AS name, 1*:kind AS kind, :attributes AS attributes) c2
+				SQL;
 		}
 		// No tag of the same name
 		$sql .= "\n" . <<<'SQL'
-WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = :name2)
-SQL;
+			WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = :name2)
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 
 		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		if (!isset($valuesTmp['attributes'])) {
 			$valuesTmp['attributes'] = [];
 		}
-		if ($stm !== false) {
-			if (!empty($valuesTmp['id'])) {
-				$stm->bindValue(':id', $valuesTmp['id'], PDO::PARAM_INT);
-			}
-			$stm->bindValue(':name1', $valuesTmp['name'], PDO::PARAM_STR);
-			$stm->bindValue(':kind', $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL, PDO::PARAM_INT);
-			$attributes = is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
-				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
-			$stm->bindValue(':attributes', $attributes, PDO::PARAM_STR);
-			$stm->bindValue(':name2', $valuesTmp['name'], PDO::PARAM_STR);
-		}
-		if ($stm !== false && $stm->execute() && $stm->rowCount() > 0) {
+
+		if ($stm !== false &&
+			(empty($valuesTmp['id']) || $stm->bindValue(':id', $valuesTmp['id'], PDO::PARAM_INT)) &&
+			$stm->bindValue(':name1', $valuesTmp['name'], PDO::PARAM_STR) &&
+			$stm->bindValue(':kind', $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL, PDO::PARAM_INT) &&
+			$stm->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR) &&
+			$stm->bindValue(':name2', $valuesTmp['name'], PDO::PARAM_STR) &&
+			$stm->execute() && $stm->rowCount() > 0) {
 			if (empty($valuesTmp['id'])) {
 				// Auto-generated ID
 				$catId = $this->pdo->lastInsertId('`_category_id_seq`');
@@ -186,24 +182,24 @@ SQL;
 	public function updateCategory(int $id, array $valuesTmp): int|false {
 		// No tag of the same name
 		$sql = <<<'SQL'
-UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
-AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?)
-SQL;
+			UPDATE `_category` SET name=:name, kind=:kind, attributes=:attributes WHERE id=:id
+			AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = :name2)
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 
 		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		if (empty($valuesTmp['attributes'])) {
 			$valuesTmp['attributes'] = [];
 		}
-		$values = [
-			$valuesTmp['name'],
-			$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
-			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
-			$id,
-			$valuesTmp['name'],
-		];
-
-		if ($stm !== false && $stm->execute($values)) {
+
+		if ($stm !== false &&
+			$stm->bindValue(':name', $valuesTmp['name'], PDO::PARAM_STR) &&
+			$stm->bindValue(':kind', $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL, PDO::PARAM_INT) &&
+			$stm->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR) &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':name2', $valuesTmp['name'], PDO::PARAM_STR) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -217,15 +213,15 @@ SQL;
 	}
 
 	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0): int|false {
-		$sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
-		$values = [
-			$mtime <= 0 ? time() : $mtime,
-			$inError ? 1 : 0,
-			$id,
-		];
+		$sql = <<<'SQL'
+			UPDATE `_category` SET `lastUpdate`=:last_update, error=:error WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':last_update', $mtime <= 0 ? time() : $mtime, PDO::PARAM_INT) &&
+			$stm->bindValue(':error', $inError ? 1 : 0, PDO::PARAM_INT) &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -235,9 +231,11 @@ SQL;
 	}
 
 	public function deleteCategory(int $id): int|false {
-		$sql = 'DELETE FROM `_category` WHERE id=:id';
+		$sql = <<<'SQL'
+			DELETE FROM `_category` WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-		if ($stm !== false && $stm->bindParam(':id', $id, PDO::PARAM_INT) && $stm->execute()) {
+		if ($stm !== false && $stm->bindValue(':id', $id, PDO::PARAM_INT) && $stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -248,7 +246,9 @@ SQL;
 
 	/** @return Traversable<array{id:int,name:string,kind:int,lastUpdate:int,error:int,attributes?:array<string,mixed>}> */
 	public function selectAll(): Traversable {
-		$sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`';
+		$sql = <<<'SQL'
+			SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
@@ -267,7 +267,9 @@ SQL;
 	}
 
 	public function searchById(int $id): ?FreshRSS_Category {
-		$sql = 'SELECT * FROM `_category` WHERE id=:id';
+		$sql = <<<'SQL'
+			SELECT * FROM `_category` WHERE id=:id
+			SQL;
 		$res = $this->fetchAssoc($sql, ['id' => $id]) ?? [];
 		/** @var list<array{name:string,id:int,kind:int,lastUpdate?:int,error:int,attributes?:string}> $res */
 		$categories = self::daoToCategories($res);
@@ -275,7 +277,9 @@ SQL;
 	}
 
 	public function searchByName(string $name): ?FreshRSS_Category {
-		$sql = 'SELECT * FROM `_category` WHERE name=:name';
+		$sql = <<<'SQL'
+			SELECT * FROM `_category` WHERE name=:name
+			SQL;
 		$res = $this->fetchAssoc($sql, ['name' => $name]) ?? [];
 		/** @var list<array{name:string,id:int,kind:int,lastUpdate:int,error:int,attributes:string}> $res */
 		$categories = self::daoToCategories($res);
@@ -305,12 +309,17 @@ SQL;
 	/** @return array<int,FreshRSS_Category> where the key is the category ID */
 	public function listCategories(bool $prePopulateFeeds = true, bool $details = false): array {
 		if ($prePopulateFeeds) {
-			$sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, '
-				. ($details ? 'f.* ' : 'f.id, f.name, f.url, f.kind, f.website, f.priority, f.error, f.attributes, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ')
-				. 'FROM `_category` c '
-				. 'LEFT OUTER JOIN `_feed` f ON f.category=c.id '
-				. 'GROUP BY f.id, c_id '
-				. 'ORDER BY c.name, f.name';
+			$feedFields = $details ?
+				'f.*' :
+				'f.id, f.name, f.url, f.kind, f.website, f.priority, f.error, f.attributes, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl';
+			$sql = <<<SQL
+				SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes,
+					{$feedFields}
+				FROM `_category` c
+				LEFT OUTER JOIN `_feed` f ON f.category=c.id
+				GROUP BY f.id, c_id
+				ORDER BY c.name, f.name
+				SQL;
 			$stm = $this->pdo->prepare($sql);
 			if ($stm !== false && $stm->execute() && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
 				/** @var list<array{c_name:string,c_id:int,c_kind:int,c_last_update:int,c_error:int,c_attributes?:string,
@@ -334,8 +343,10 @@ SQL;
 
 	/** @return array<int,FreshRSS_Category> where the key is the category ID */
 	public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0): array {
-		$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
-			. ($limit < 1 ? '' : ' LIMIT ' . $limit);
+		$limitSql = $limit < 1 ? '' : 'LIMIT ' . $limit;
+		$sql = <<<SQL
+			SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate` {$limitSql}
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false &&
 			$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
@@ -356,7 +367,9 @@ SQL;
 	}
 
 	public function getDefault(): ?FreshRSS_Category {
-		$sql = 'SELECT * FROM `_category` WHERE id=:id';
+		$sql = <<<'SQL'
+			SELECT * FROM `_category` WHERE id=:id
+			SQL;
 		$res = $this->fetchAssoc($sql, [':id' => self::DEFAULTCATEGORYID]) ?? [];
 		/** @var list<array{name:string,id:int,kind:int,lastUpdate?:int,error?:int,attributes?:string}> $res */
 		$categories = self::daoToCategories($res);
@@ -377,15 +390,15 @@ SQL;
 		if ($def_cat == null) {
 			$cat = new FreshRSS_Category(_t('gen.short.default_category'), self::DEFAULTCATEGORYID);
 
-			$sql = 'INSERT INTO `_category`(id, name) VALUES(?, ?)';
+			$sql = <<<'SQL'
+				INSERT INTO `_category`(id, name) VALUES(:id, :name)
+				SQL;
 			$stm = $this->pdo->prepare($sql);
 
-			$values = [
-				$cat->id(),
-				$cat->name(),
-			];
-
-			if ($stm !== false && $stm->execute($values)) {
+			if ($stm !== false &&
+				$stm->bindValue(':id', $cat->id(), PDO::PARAM_INT) &&
+				$stm->bindValue(':name', $cat->name(), PDO::PARAM_STR) &&
+				$stm->execute()) {
 				$catId = $this->pdo->lastInsertId('`_category_id_seq`');
 				$this->sqlResetSequence();
 				return $catId === false ? false : (int)$catId;
@@ -399,15 +412,17 @@ SQL;
 	}
 
 	public function count(): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_category`';
-		$res = $this->fetchColumn($sql, 0);
-		return isset($res[0]) ? (int)$res[0] : -1;
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_category`
+			SQL;
+		return $this->fetchInt($sql) ?? -1;
 	}
 
 	public function countFeed(int $id): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_feed` WHERE category=:id';
-		$res = $this->fetchColumn($sql, 0, [':id' => $id]);
-		return isset($res[0]) ? (int)$res[0] : -1;
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_feed` WHERE category=:id
+			SQL;
+		return $this->fetchInt($sql, [':id' => $id]) ?? -1;
 	}
 
 	public function countNotRead(int $id, int $minPriority = FreshRSS_Feed::PRIORITY_CATEGORY): int {
@@ -416,9 +431,8 @@ SQL;
 			INNER JOIN `_feed` f ON e.id_feed=f.id
 			WHERE f.category=:id AND e.is_read=0
 			AND f.priority>=:minPriority
-		SQL;
-		$res = $this->fetchColumn($sql, 0, [':id' => $id, ':minPriority' => $minPriority]);
-		return isset($res[0]) ? (int)$res[0] : -1;
+			SQL;
+		return $this->fetchInt($sql, [':id' => $id, ':minPriority' => $minPriority]) ?? -1;
 	}
 
 	/** @return list<string> */
@@ -428,7 +442,7 @@ SQL;
 			INNER JOIN `_feed` f ON e.id_feed=f.id
 			WHERE f.category=:id_category
 			ORDER BY e.id DESC
-		SQL;
+			SQL;
 		$sql .= ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
 		$res = $this->fetchColumn($sql, 0, [':id_category' => $id]) ?? [];
 		/** @var list<string> $res */

+ 2 - 2
app/Models/CategoryDAOPGSQL.php

@@ -6,8 +6,8 @@ final class FreshRSS_CategoryDAOPGSQL extends FreshRSS_CategoryDAO {
 	#[\Override]
 	public function sqlResetSequence(): bool {
 		$sql = <<<'SQL'
-SELECT setval('`_category_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_category`
-SQL;
+			SELECT setval('`_category_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_category`
+			SQL;
 		return $this->pdo->exec($sql) !== false;
 	}
 }

+ 2 - 2
app/Models/CategoryDAOSQLite.php

@@ -20,8 +20,8 @@ class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO {
 				}
 			}
 		}
-		if (($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) !== false) {
-			$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
+		$columns = $this->fetchColumn("PRAGMA table_info('category')", 1);
+		if ($columns !== null) {
 			foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
 				if (!in_array($column, $columns, true)) {
 					return $this->addColumn($column);

+ 21 - 11
app/Models/DatabaseDAO.php

@@ -39,7 +39,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	public function testConnection(): string {
 		try {
-			$sql = 'SELECT 1';
+			$sql = <<<'SQL'
+				SELECT 1
+				SQL;
 			$stm = $this->pdo->query($sql);
 			if ($stm === false) {
 				return 'Error during SQL connection test!';
@@ -53,7 +55,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 	}
 
 	public function exits(): bool {
-		$sql = 'SELECT * FROM `_entry` LIMIT 1';
+		$sql = <<<'SQL'
+			SELECT * FROM `_entry` LIMIT 1
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
@@ -230,7 +234,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 	}
 
 	protected function selectVersion(): string {
-		return $this->fetchValue('SELECT version()') ?? '';
+		return $this->fetchString('SELECT version()') ?? '';
 	}
 
 	public function version(): string {
@@ -258,7 +262,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 	 * @return bool true if the database PDO driver returns typed integer values as it should, false otherwise.
 	 */
 	final public function testTyping(): bool {
-		$sql = 'SELECT 2 + 3';
+		$sql = <<<'SQL'
+			SELECT 2 + 3
+			SQL;
 		if (($stm = $this->pdo->query($sql)) !== false) {
 			$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
 			return ($res[0] ?? null) === 5;
@@ -271,8 +277,8 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 		// MariaDB does not refresh size information automatically
 		$sql = <<<'SQL'
-ANALYZE TABLE `_category`, `_feed`, `_entry`, `_entrytmp`, `_tag`, `_entrytag`
-SQL;
+			ANALYZE TABLE `_category`, `_feed`, `_entry`, `_entrytmp`, `_tag`, `_entrytag`
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			$stm->fetchAll();
@@ -280,12 +286,14 @@ SQL;
 
 		//MySQL:
 		$sql = <<<'SQL'
-SELECT SUM(DATA_LENGTH + INDEX_LENGTH + DATA_FREE)
-FROM information_schema.TABLES WHERE TABLE_SCHEMA=:table_schema
-SQL;
+			SELECT SUM(DATA_LENGTH + INDEX_LENGTH + DATA_FREE)
+			FROM information_schema.TABLES WHERE TABLE_SCHEMA=:table_schema
+			SQL;
 		$values = [':table_schema' => $db['base']];
 		if (!$all) {
-			$sql .= ' AND table_name LIKE :table_name';
+			$sql .= "\n" . <<<'SQL'
+				AND table_name LIKE :table_name
+				SQL;
 			$values[':table_name'] = addcslashes($this->pdo->prefix(), '\\%_') . '%';
 		}
 		$res = $this->fetchColumn($sql, 0, $values);
@@ -297,7 +305,9 @@ SQL;
 		$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];
 
 		foreach ($tables as $table) {
-			$sql = 'OPTIMIZE TABLE `_' . $table . '`';	//MySQL
+			$sql = <<<SQL
+				OPTIMIZE TABLE `_{$table}`
+				SQL;	//MySQL
 			$stm = $this->pdo->query($sql);
 			if ($stm === false || $stm->fetchAll(PDO::FETCH_ASSOC) == false) {
 				$ok = false;

+ 18 - 14
app/Models/DatabaseDAOPGSQL.php

@@ -13,7 +13,9 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
 	#[\Override]
 	public function tablesAreCorrect(): bool {
 		$db = FreshRSS_Context::systemConf()->db;
-		$sql = 'SELECT tablename FROM pg_catalog.pg_tables where tableowner=:tableowner';
+		$sql = <<<'SQL'
+			SELECT tablename FROM pg_catalog.pg_tables where tableowner=:tableowner
+			SQL;
 		$res = $this->fetchAssoc($sql, [':tableowner' => $db['user']]);
 		if ($res == null) {
 			return false;
@@ -38,9 +40,9 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
 	#[\Override]
 	public function getSchema(string $table): array {
 		$sql = <<<'SQL'
-SELECT column_name AS field, data_type AS type, column_default AS default, is_nullable AS null
-FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = :table_name
-SQL;
+			SELECT column_name AS field, data_type AS type, column_default AS default, is_nullable AS null
+			FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = :table_name
+			SQL;
 		$res = $this->fetchAssoc($sql, [':table_name' => $this->pdo->prefix() . $table]);
 		return $res == null ? [] : $this->listDaoToSchema($res);
 	}
@@ -61,7 +63,7 @@ SQL;
 
 	#[\Override]
 	protected function selectVersion(): string {
-		return $this->fetchValue('SELECT version()') ?? '';
+		return $this->fetchString('SELECT version()') ?? '';
 	}
 
 	#[\Override]
@@ -71,14 +73,14 @@ SQL;
 			$res = $this->fetchColumn('SELECT pg_database_size(:base)', 0, [':base' => $db['base']]);
 		} else {
 			$sql = <<<SQL
-SELECT
-pg_total_relation_size('`{$this->pdo->prefix()}category`') +
-pg_total_relation_size('`{$this->pdo->prefix()}feed`') +
-pg_total_relation_size('`{$this->pdo->prefix()}entry`') +
-pg_total_relation_size('`{$this->pdo->prefix()}entrytmp`') +
-pg_total_relation_size('`{$this->pdo->prefix()}tag`') +
-pg_total_relation_size('`{$this->pdo->prefix()}entrytag`')
-SQL;
+				SELECT
+				pg_total_relation_size('`{$this->pdo->prefix()}category`') +
+				pg_total_relation_size('`{$this->pdo->prefix()}feed`') +
+				pg_total_relation_size('`{$this->pdo->prefix()}entry`') +
+				pg_total_relation_size('`{$this->pdo->prefix()}entrytmp`') +
+				pg_total_relation_size('`{$this->pdo->prefix()}tag`') +
+				pg_total_relation_size('`{$this->pdo->prefix()}entrytag`')
+				SQL;
 			$res = $this->fetchColumn($sql, 0);
 		}
 		return (int)($res[0] ?? -1);
@@ -90,7 +92,9 @@ SQL;
 		$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];
 
 		foreach ($tables as $table) {
-			$sql = 'VACUUM `_' . $table . '`';
+			$sql = <<<SQL
+				VACUUM `_{$table}`
+				SQL;
 			if ($this->pdo->exec($sql) === false) {
 				$ok = false;
 				$info = $this->pdo->errorInfo();

+ 9 - 8
app/Models/DatabaseDAOSQLite.php

@@ -9,9 +9,8 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 	#[\Override]
 	public function tablesAreCorrect(): bool {
 		$sql = "SELECT name FROM sqlite_master WHERE type='table'";
-		$stm = $this->pdo->query($sql);
-		$res = $stm !== false ? $stm->fetchAll(PDO::FETCH_ASSOC) : false;
-		if ($res === false) {
+		$res = $this->fetchAssoc($sql);
+		if ($res === null) {
 			return false;
 		}
 
@@ -35,10 +34,12 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 	/** @return list<array{name:string,type:string,notnull:bool,default:mixed}> */
 	#[\Override]
 	public function getSchema(string $table): array {
-		$sql = 'PRAGMA table_info(' . $table . ')';
-		$stm = $this->pdo->query($sql);
-		if ($stm !== false && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
-			/** @var list<array{name:string,type:string,notnull:bool,dflt_value:string|int|bool|null}> $res */
+		$sql = <<<SQL
+			PRAGMA table_info('{$table}')
+			SQL;
+		$res = $this->fetchAssoc($sql);
+		if ($res !== null) {
+			/** @var list<array{name:string,type:string,notnull:bool|int,dflt_value:string|int|bool|null}> $res */
 			return $this->listDaoToSchema($res);
 		}
 		return [];
@@ -60,7 +61,7 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 
 	#[\Override]
 	protected function selectVersion(): string {
-		return $this->fetchValue('SELECT sqlite_version()') ?? '';
+		return $this->fetchString('SELECT sqlite_version()') ?? '';
 	}
 
 	#[\Override]

+ 6 - 6
app/Models/Entry.php

@@ -229,12 +229,12 @@ class FreshRSS_Entry extends Minz_Model {
 			$elink = $thumbnailAttribute['url'];
 			if (is_string($elink) && ($allowDuplicateEnclosures || !self::containsLink($content, $elink))) {
 				$content .= <<<HTML
-<figure class="enclosure">
-	<p class="enclosure-content">
-		<img class="enclosure-thumbnail" src="{$elink}" alt="" />
-	</p>
-</figure>
-HTML;
+					<figure class="enclosure">
+						<p class="enclosure-content">
+							<img class="enclosure-thumbnail" src="{$elink}" alt="" />
+						</p>
+					</figure>
+					HTML;
 			}
 		}
 

+ 240 - 165
app/Models/EntryDAO.php

@@ -102,9 +102,9 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		Minz_Log::warning('Update MySQL table to use MEDIUMBLOB...');
 
 		$sql = <<<'SQL'
-ALTER TABLE `_entry` MODIFY `content_bin` MEDIUMBLOB;
-ALTER TABLE `_entrytmp` MODIFY `content_bin` MEDIUMBLOB;
-SQL;
+			ALTER TABLE `_entry` MODIFY `content_bin` MEDIUMBLOB;
+			ALTER TABLE `_entrytmp` MODIFY `content_bin` MEDIUMBLOB;
+			SQL;
 		try {
 			$ok = $this->pdo->exec($sql) !== false;
 		} catch (Exception $e) {
@@ -129,9 +129,9 @@ SQL;
 		try {
 			if ($name === 'attributes') {	//v1.20.0
 				$sql = <<<'SQL'
-ALTER TABLE `_entry` ADD COLUMN attributes TEXT;
-ALTER TABLE `_entrytmp` ADD COLUMN attributes TEXT;
-SQL;
+					ALTER TABLE `_entry` ADD COLUMN attributes TEXT;
+					ALTER TABLE `_entrytmp` ADD COLUMN attributes TEXT;
+					SQL;
 				$result = $this->pdo->exec($sql);
 			} elseif ($name === 'lastUserModified') {	//v1.28.0
 				$sql = $GLOBALS['ALTER_TABLE_ENTRY_LAST_USER_MODIFIED'];
@@ -203,34 +203,34 @@ SQL;
 			$this->addEntryPrepared = $this->pdo->prepare($sql);
 		}
 		if ($this->addEntryPrepared != false) {
-			$this->addEntryPrepared->bindParam(':id', $valuesTmp['id']);
+			$this->addEntryPrepared->bindValue(':id', $valuesTmp['id']);
 			$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
 			$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
-			$this->addEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
+			$this->addEntryPrepared->bindValue(':guid', $valuesTmp['guid']);
 			$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
 			$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
-			$this->addEntryPrepared->bindParam(':title', $valuesTmp['title']);
+			$this->addEntryPrepared->bindValue(':title', $valuesTmp['title']);
 			$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
 			$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
-			$this->addEntryPrepared->bindParam(':author', $valuesTmp['author']);
+			$this->addEntryPrepared->bindValue(':author', $valuesTmp['author']);
 			$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
-			$this->addEntryPrepared->bindParam(':content', $valuesTmp['content']);
+			$this->addEntryPrepared->bindValue(':content', $valuesTmp['content']);
 			$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
 			$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
-			$this->addEntryPrepared->bindParam(':link', $valuesTmp['link']);
-			$this->addEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
+			$this->addEntryPrepared->bindValue(':link', $valuesTmp['link']);
+			$this->addEntryPrepared->bindValue(':date', $valuesTmp['date'], PDO::PARAM_INT);
 			if (empty($valuesTmp['lastSeen'])) {
 				$valuesTmp['lastSeen'] = time();
 			}
-			$this->addEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
+			$this->addEntryPrepared->bindValue(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
 			$valuesTmp['is_read'] = $valuesTmp['is_read'] ? 1 : 0;
-			$this->addEntryPrepared->bindParam(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
+			$this->addEntryPrepared->bindValue(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
 			$valuesTmp['is_favorite'] = $valuesTmp['is_favorite'] ? 1 : 0;
-			$this->addEntryPrepared->bindParam(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
-			$this->addEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
+			$this->addEntryPrepared->bindValue(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
+			$this->addEntryPrepared->bindValue(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
 			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, 'UTF-8');
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
-			$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+			$this->addEntryPrepared->bindValue(':tags', $valuesTmp['tags']);
 			if (!isset($valuesTmp['attributes'])) {
 				$valuesTmp['attributes'] = [];
 			}
@@ -238,10 +238,10 @@ SQL;
 				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
 
 			if (static::hasNativeHex()) {
-				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
+				$this->addEntryPrepared->bindValue(':hash', $valuesTmp['hash']);
 			} else {
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
-				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
+				$this->addEntryPrepared->bindValue(':hash', $valuesTmp['hashBin']);
 			}
 		}
 		if ($this->addEntryPrepared != false && $this->addEntryPrepared->execute()) {
@@ -308,36 +308,38 @@ SQL;
 			$valuesTmp['lastModified'] = null;
 		}
 		if ($this->updateEntryPrepared == null) {
-			$sql = 'UPDATE `_entry` '
-				. 'SET title=:title, author=:author, '
-				. (static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content')
-				. ', link=:link, date=:date, `lastSeen`=:last_seen'
-				. ', `lastModified`=COALESCE(:last_modified, `lastModified`)'
-				. ', `lastUserModified`=COALESCE(:last_user_modified, `lastUserModified`)'
-				. ', hash=' . static::sqlHexDecode(':hash')
-				. ', is_read=COALESCE(:is_read, is_read)'
-				. ', is_favorite=COALESCE(:is_favorite, is_favorite)'
-				. ', tags=:tags, attributes=:attributes '
-				. 'WHERE id_feed=:id_feed AND guid=:guid';
+			$contentAssign = static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content';
+			$hashExpr = static::sqlHexDecode(':hash');
+			$sql = <<<SQL
+				UPDATE `_entry`
+				SET title=:title, author=:author, {$contentAssign}, link=:link, date=:date, `lastSeen`=:last_seen,
+					`lastModified`=COALESCE(:last_modified, `lastModified`),
+					`lastUserModified`=COALESCE(:last_user_modified, `lastUserModified`),
+					hash={$hashExpr},
+					is_read=COALESCE(:is_read, is_read),
+					is_favorite=COALESCE(:is_favorite, is_favorite),
+					tags=:tags, attributes=:attributes
+				WHERE id_feed=:id_feed AND guid=:guid
+				SQL;
 			$this->updateEntryPrepared = $this->pdo->prepare($sql);
 		}
 		if ($this->updateEntryPrepared != false) {
 			$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
 			$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
-			$this->updateEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
+			$this->updateEntryPrepared->bindValue(':guid', $valuesTmp['guid']);
 			$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
 			$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
-			$this->updateEntryPrepared->bindParam(':title', $valuesTmp['title']);
+			$this->updateEntryPrepared->bindValue(':title', $valuesTmp['title']);
 			$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
 			$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
-			$this->updateEntryPrepared->bindParam(':author', $valuesTmp['author']);
+			$this->updateEntryPrepared->bindValue(':author', $valuesTmp['author']);
 			$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
-			$this->updateEntryPrepared->bindParam(':content', $valuesTmp['content']);
+			$this->updateEntryPrepared->bindValue(':content', $valuesTmp['content']);
 			$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
 			$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
-			$this->updateEntryPrepared->bindParam(':link', $valuesTmp['link']);
-			$this->updateEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
-			$this->updateEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
+			$this->updateEntryPrepared->bindValue(':link', $valuesTmp['link']);
+			$this->updateEntryPrepared->bindValue(':date', $valuesTmp['date'], PDO::PARAM_INT);
+			$this->updateEntryPrepared->bindValue(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
 			if ($valuesTmp['lastModified'] === null) {
 				$this->updateEntryPrepared->bindValue(':last_modified', null, PDO::PARAM_NULL);
 			} else {
@@ -358,10 +360,10 @@ SQL;
 			} else {
 				$this->updateEntryPrepared->bindValue(':is_favorite', $valuesTmp['is_favorite'] ? 1 : 0, PDO::PARAM_INT);
 			}
-			$this->updateEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
+			$this->updateEntryPrepared->bindValue(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
 			$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, 'UTF-8');
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
-			$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+			$this->updateEntryPrepared->bindValue(':tags', $valuesTmp['tags']);
 			if (!isset($valuesTmp['attributes'])) {
 				$valuesTmp['attributes'] = [];
 			}
@@ -369,10 +371,10 @@ SQL;
 				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
 
 			if (static::hasNativeHex()) {
-				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
+				$this->updateEntryPrepared->bindValue(':hash', $valuesTmp['hash']);
 			} else {
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
-				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
+				$this->updateEntryPrepared->bindValue(':hash', $valuesTmp['hashBin']);
 			}
 		}
 
@@ -398,10 +400,9 @@ SQL;
 	 */
 	public function countNewEntries(): int {
 		$sql = <<<'SQL'
-		SELECT COUNT(id) AS nb_entries FROM `_entrytmp`
-		SQL;
-		$res = $this->fetchColumn($sql, 0);
-		return isset($res[0]) ? (int)$res[0] : -1;
+			SELECT COUNT(id) AS nb_entries FROM `_entrytmp`
+			SQL;
+		return $this->fetchInt($sql) ?? -1;
 	}
 
 	/**
@@ -429,9 +430,12 @@ SQL;
 			}
 			return $affected;
 		}
-		$sql = 'UPDATE `_entry` '
-			. 'SET is_favorite=?, `lastUserModified`=? '
-			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
+		$idPlaceholders = str_repeat('?,', count($ids) - 1) . '?';
+		$sql = <<<SQL
+			UPDATE `_entry`
+			SET is_favorite=?, `lastUserModified`=?
+			WHERE id IN ({$idPlaceholders})
+			SQL;
 		$values = [$is_favorite ? 1 : 0];
 		$values[] = time();
 		$values = array_merge($values, $ids);
@@ -456,27 +460,27 @@ SQL;
 		$useIndex = $this->pdo->dbType() === 'mysql' ? 'USE INDEX (entry_feed_read_index)' : '';
 
 		$sql = <<<SQL
-UPDATE `_feed`
-SET `cache_nbUnreads`=(
-	SELECT COUNT(*) AS nbUnreads FROM `_entry` e {$useIndex}
-	WHERE e.id_feed=`_feed`.id AND e.is_read=0)
-SQL;
-		$hasWhere = false;
-		$values = [];
+			UPDATE `_feed`
+			SET `cache_nbUnreads`=(
+				SELECT COUNT(*) AS nbUnreads FROM `_entry` e {$useIndex}
+				WHERE e.id_feed=`_feed`.id AND e.is_read=0)
+			WHERE 1=1
+			SQL;
 		if ($feedId != null) {
-			$sql .= ' WHERE';
-			$hasWhere = true;
-			$sql .= ' id=?';
-			$values[] = $feedId;
+			$sql .= "\n" . <<<'SQL'
+				AND id=:feed_id
+				SQL;
 		}
 		if ($catId != null) {
-			$sql .= $hasWhere ? ' AND' : ' WHERE';
-			$hasWhere = true;
-			$sql .= ' category=?';
-			$values[] = $catId;
+			$sql .= "\n" . <<<'SQL'
+				AND category=:cat_id
+				SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			($catId == null || $stm->bindValue(':cat_id', $catId, PDO::PARAM_INT)) &&
+			($feedId == null || $stm->bindValue(':feed_id', $feedId, PDO::PARAM_INT)) &&
+			 $stm->execute()) {
 			return true;
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -511,9 +515,12 @@ SQL;
 			}
 
 			FreshRSS_UserDAO::touch();
-			$sql = 'UPDATE `_entry` '
-				 . 'SET is_read=?, `lastUserModified`=? '
-				 . 'WHERE is_read<>? AND id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
+			$idPlaceholders = str_repeat('?,', count($ids) - 1) . '?';
+			$sql = <<<SQL
+				UPDATE `_entry`
+				SET is_read=?, `lastUserModified`=?
+				WHERE is_read<>? AND id IN ({$idPlaceholders})
+				SQL;
 			$values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0];
 			$values = array_merge($values, $ids);
 			$stm = $this->pdo->prepare($sql);
@@ -534,13 +541,20 @@ SQL;
 			return $affected;
 		} else {
 			FreshRSS_UserDAO::touch();
-			$sql = 'UPDATE `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id '
-				 . 'SET e.is_read=?,`lastUserModified`=?,'
-				 . 'f.`cache_nbUnreads`=f.`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
-				 . 'WHERE e.id=? AND e.is_read=?';
-			$values = [$is_read ? 1 : 0, time(), $ids, $is_read ? 0 : 1];
+			$delta = $is_read ? '-1' : '+1';
+			$sql = <<<SQL
+				UPDATE `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id
+				SET e.is_read=:is_read,`lastUserModified`=:last_user_modified,
+				f.`cache_nbUnreads`=f.`cache_nbUnreads` {$delta}
+				WHERE e.id=:id AND e.is_read=:old_is_read
+				SQL;
 			$stm = $this->pdo->prepare($sql);
-			if ($stm !== false && $stm->execute($values)) {
+			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(':old_is_read', $is_read ? 0 : 1, PDO::PARAM_INT) &&
+				$stm->execute()) {
 				return $stm->rowCount();
 			} else {
 				$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -576,22 +590,34 @@ SQL;
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
 		}
 
-		$sql = 'UPDATE `_entry` SET is_read = ?, `lastUserModified`=? WHERE is_read <> ? AND id <= ?';
+		$sql = <<<'SQL'
+			UPDATE `_entry` SET is_read = ?, `lastUserModified`=? WHERE is_read <> ? AND id <= ?
+			SQL;
 		$values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0, $idMax];
 		if ($onlyFavorites) {
-			$sql .= ' AND is_favorite=1';
+			$sql .= "\n" . <<<'SQL'
+				AND is_favorite=1
+				SQL;
 		}
 		if ($priorityMin !== null || $priorityMax !== null) {
-			$sql .= ' AND id_feed IN (SELECT f.id FROM `_feed` f WHERE 1=1';
+			$sql .= "\n" . <<<'SQL'
+				AND id_feed IN (SELECT f.id FROM `_feed` f WHERE 1=1
+				SQL;
 			if ($priorityMin !== null) {
-				$sql .= ' AND f.priority >= ?';
+				$sql .= "\n" . <<<'SQL'
+					AND f.priority >= ?
+					SQL;
 				$values[] = $priorityMin;
 			}
 			if ($priorityMax !== null) {
-				$sql .= ' AND f.priority < ?';
+				$sql .= "\n" . <<<'SQL'
+					AND f.priority < ?
+					SQL;
 				$values[] = $priorityMax;
 			}
-			$sql .= ')';
+			$sql .= "\n" . <<<'SQL'
+				)
+				SQL;
 		}
 
 		[$searchValues, $search] = $this->sqlListEntriesWhere(alias: '', state: $state, filters: $filters);
@@ -628,11 +654,11 @@ SQL;
 		}
 
 		$sql = <<<'SQL'
-UPDATE `_entry`
-SET is_read = ?, `lastUserModified` = ?
-WHERE is_read <> ? AND id <= ?
-AND id_feed IN (SELECT f.id FROM `_feed` f WHERE f.category=? AND f.priority >= ? AND f.priority < ?)
-SQL;
+			UPDATE `_entry`
+			SET is_read = ?, `lastUserModified` = ?
+			WHERE is_read <> ? AND id <= ?
+			AND id_feed IN (SELECT f.id FROM `_feed` f WHERE f.category=? AND f.priority >= ? AND f.priority < ?)
+			SQL;
 		$values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0, $idMax, $id, FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Feed::PRIORITY_IMPORTANT];
 
 		[$searchValues, $search] = $this->sqlListEntriesWhere(alias: '', state: $state, filters: $filters);
@@ -672,9 +698,11 @@ SQL;
 			$this->pdo->beginTransaction();
 		}
 
-		$sql = 'UPDATE `_entry` '
-			 . 'SET is_read=?, `lastUserModified`=? '
-			 . 'WHERE id_feed=? AND is_read <> ? AND id <= ?';
+		$sql = <<<'SQL'
+			UPDATE `_entry`
+			SET is_read=?, `lastUserModified`=?
+			WHERE id_feed=? AND is_read <> ? AND id <= ?
+			SQL;
 		$values = [$is_read ? 1 : 0, time(), $id_feed, $is_read ? 1 : 0, $idMax];
 
 		[$searchValues, $search] = $this->sqlListEntriesWhere(alias: '', state: $state, filters: $filters);
@@ -689,12 +717,14 @@ SQL;
 		$affected = $stm->rowCount();
 
 		if ($affected > 0) {
-			$sql = 'UPDATE `_feed` '
-				 . 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
-				 . ' WHERE id=:id';
+			$sql = <<<SQL
+				UPDATE `_feed`
+				SET `cache_nbUnreads`=`cache_nbUnreads`-{$affected}
+				WHERE id=:id
+				SQL;
 			$stm = $this->pdo->prepare($sql);
 			if (!($stm !== false &&
-				$stm->bindParam(':id', $id_feed, PDO::PARAM_INT) &&
+				$stm->bindValue(':id', $id_feed, PDO::PARAM_INT) &&
 				$stm->execute())) {
 				$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
 				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
@@ -723,11 +753,12 @@ SQL;
 			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
 		}
 
-		$sql = 'UPDATE `_entry` e INNER JOIN `_entrytag` et ON et.id_entry = e.id '
-			 . 'SET e.is_read = ?, `lastUserModified` = ? '
-			 . 'WHERE '
-			 . ($id == 0 ? '' : 'et.id_tag = ? AND ')
-			 . 'e.is_read <> ? AND e.id <= ?';
+		$tagCondition = $id == 0 ? '' : 'et.id_tag = ? AND ';
+		$sql = <<<SQL
+			UPDATE `_entry` e INNER JOIN `_entrytag` et ON et.id_entry = e.id
+			SET e.is_read = ?, `lastUserModified` = ?
+			WHERE {$tagCondition} e.is_read <> ? AND e.id <= ?
+			SQL;
 		$values = [$is_read ? 1 : 0, time()];
 		if ($id != 0) {
 			$values[] = $id;
@@ -755,49 +786,69 @@ SQL;
 	 * @param array<string,bool|int|string> $options
 	 */
 	public function cleanOldEntries(int $id_feed, array $options = []): int|false {
-		$sql = 'DELETE FROM `_entry` WHERE id_feed = :id_feed1';	//No alias for MySQL / MariaDB
+		$sql = <<<'SQL'
+			DELETE FROM `_entry` WHERE id_feed = :id_feed1
+			SQL;	//No alias for MySQL / MariaDB
 		$params = [];
 		$params[':id_feed1'] = $id_feed;
 
 		//==Exclusions==
 		if (!empty($options['keep_favourites'])) {
-			$sql .= ' AND is_favorite = 0';
+			$sql .= "\n" . <<<'SQL'
+				AND is_favorite = 0
+				SQL;
 		}
 		if (!empty($options['keep_unreads'])) {
-			$sql .= ' AND is_read = 1';
+			$sql .= "\n" . <<<'SQL'
+				AND is_read = 1
+				SQL;
 		}
 		if (!empty($options['keep_labels'])) {
-			$sql .= ' AND NOT EXISTS (SELECT 1 FROM `_entrytag` WHERE id_entry = id)';
+			$sql .= "\n" . <<<'SQL'
+				AND NOT EXISTS (SELECT 1 FROM `_entrytag` WHERE id_entry = id)
+				SQL;
 		}
 		if (!empty($options['keep_min']) && $options['keep_min'] > 0) {
 			//Double SELECT for MySQL workaround ERROR 1093 (HY000)
-			$sql .= ' AND `lastSeen` < (SELECT `lastSeen`'
-				. ' FROM (SELECT e2.`lastSeen` FROM `_entry` e2 WHERE e2.id_feed = :id_feed2'
-				. ' ORDER BY e2.`lastSeen` DESC LIMIT 1 OFFSET :keep_min) last_seen2)';
+			$sql .= "\n" . <<<'SQL'
+				AND `lastSeen` < (SELECT `lastSeen`
+				FROM (SELECT e2.`lastSeen` FROM `_entry` e2 WHERE e2.id_feed = :id_feed2
+				ORDER BY e2.`lastSeen` DESC LIMIT 1 OFFSET :keep_min) last_seen2)
+				SQL;
 			$params[':id_feed2'] = $id_feed;
 			$params[':keep_min'] = (int)$options['keep_min'];
 		}
 		//Keep at least the articles seen at the last refresh
-		$sql .= ' AND `lastSeen` < (SELECT maxlastseen'
-			. ' FROM (SELECT MAX(e3.`lastSeen`) AS maxlastseen FROM `_entry` e3 WHERE e3.id_feed = :id_feed3) last_seen3)';
+		$sql .= "\n" . <<<'SQL'
+			AND `lastSeen` < (SELECT maxlastseen
+			FROM (SELECT MAX(e3.`lastSeen`) AS maxlastseen FROM `_entry` e3 WHERE e3.id_feed = :id_feed3) last_seen3)
+			SQL;
 		$params[':id_feed3'] = $id_feed;
 
 		//==Inclusions==
-		$sql .= ' AND (1=0';
+		$sql .= "\n" . <<<'SQL'
+			AND (1=0
+			SQL;
 		if (!empty($options['keep_period']) && is_string($options['keep_period'])) {
-			$sql .= ' OR `lastSeen` < :max_last_seen';
+			$sql .= "\n" . <<<'SQL'
+				OR `lastSeen` < :max_last_seen
+				SQL;
 			$now = new DateTime('now');
 			$now->sub(new DateInterval($options['keep_period']));
 			$params[':max_last_seen'] = $now->format('U');
 		}
 		if (!empty($options['keep_max']) && $options['keep_max'] > 0) {
-			$sql .= ' OR `lastSeen` <= (SELECT `lastSeen`'
-				. ' FROM (SELECT e4.`lastSeen` FROM `_entry` e4 WHERE e4.id_feed = :id_feed4'
-				. ' ORDER BY e4.`lastSeen` DESC LIMIT 1 OFFSET :keep_max) last_seen4)';
+			$sql .= "\n" . <<<'SQL'
+				OR `lastSeen` <= (SELECT `lastSeen`
+				FROM (SELECT e4.`lastSeen` FROM `_entry` e4 WHERE e4.id_feed = :id_feed4
+				ORDER BY e4.`lastSeen` DESC LIMIT 1 OFFSET :keep_max) last_seen4)
+				SQL;
 			$params[':id_feed4'] = $id_feed;
 			$params[':keep_max'] = (int)$options['keep_max'];
 		}
-		$sql .= ')';
+		$sql .= "\n" . <<<'SQL'
+			)
+			SQL;
 
 		$stm = $this->pdo->prepare($sql);
 
@@ -826,11 +877,11 @@ SQL;
 		$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'ASC';
 		$sqlLimit = static::sqlLimit($limit, $offset);
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
-	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
-FROM `_entry`
-ORDER BY id {$order} {$sqlLimit}
-SQL;
+			SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+				{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
+			FROM `_entry`
+			ORDER BY id {$order} {$sqlLimit}
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
@@ -854,11 +905,11 @@ SQL;
 		$contentLength = 'LENGTH(' . (static::isCompressed() ? 'content_bin' : 'content') . ') AS content_length';
 		$hash = static::sqlHexEncode('hash');
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
-	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
-	{$contentLength}
-FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
-SQL;
+			SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+				{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
+				{$contentLength}
+			FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
+			SQL;
 		$res = $this->fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]);
 		/** @var list<array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
 		 *		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
@@ -872,11 +923,11 @@ SQL;
 		$contentLength = 'LENGTH(' . (static::isCompressed() ? 'content_bin' : 'content') . ') AS content_length';
 		$hash = static::sqlHexEncode('hash');
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
-	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
-	{$contentLength}
-FROM `_entry` WHERE id=:id
-SQL;
+			SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+				{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
+				{$contentLength}
+			FROM `_entry` WHERE id=:id
+			SQL;
 		$res = $this->fetchAssoc($sql, [':id' => $id]);
 		/** @var list<array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
 		 *		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
@@ -886,7 +937,9 @@ SQL;
 	}
 
 	public function searchIdByGuid(int $id_feed, string $guid): ?string {
-		$sql = 'SELECT id FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
+		$sql = <<<'SQL'
+			SELECT id FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
+			SQL;
 		$res = $this->fetchColumn($sql, 0, [':id_feed' => $id_feed, ':guid' => $guid]);
 		return empty($res[0]) ? null : (string)($res[0]);
 	}
@@ -1626,26 +1679,38 @@ SQL;
 		$content = static::isCompressed() ? 'UNCOMPRESS(e0.content_bin) AS content' : 'e0.content';
 		$hash = static::sqlHexEncode('e0.hash');
 		$sql = <<<SQL
-SELECT e0.id, e0.guid, e0.title, e0.author, {$content}, e0.link,
-	e0.date, e0.`lastSeen`, e0.`lastModified`, e0.`lastUserModified`, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
-FROM `_entry` e0 INNER JOIN ({$sql}) e2 ON e2.id=e0.id
-SQL;
+			SELECT e0.id, e0.guid, e0.title, e0.author, {$content}, e0.link,
+				e0.date, e0.`lastSeen`, e0.`lastModified`, e0.`lastUserModified`, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
+			FROM `_entry` e0 INNER JOIN ({$sql}) e2 ON e2.id=e0.id
+			SQL;
 		if ($sort === 'f.name' || $sort === 'c.name') {
-			$sql .= ' INNER JOIN `_feed` f0 ON f0.id = e0.id_feed ';
+			$sql .= "\n" . <<<'SQL'
+				INNER JOIN `_feed` f0 ON f0.id = e0.id_feed
+				SQL;
 		}
 		if ($sort === 'c.name') {
-			$sql .= ' INNER JOIN `_category` c0 ON c0.id = f0.category ';
+			$sql .= "\n" . <<<'SQL'
+				INNER JOIN `_category` c0 ON c0.id = f0.category
+				SQL;
 		}
-		$sql .= ' ORDER BY ' . $orderBy . ' ' . $order;
+		$sql .= "\n" . <<<SQL
+			ORDER BY {$orderBy} {$order}
+			SQL;
 		if ($sort === 'c.name') {
-			$sql .= ', f0.name ' . $order;	// Internal secondary sort
+			$sql .= "\n" . <<<SQL
+				, f0.name {$order}
+				SQL;	// Internal secondary sort
 		}
 		if (in_array($sort, ['c.name', 'f.name'], true)) {
-			$sql .= ', ' . $orderBy2 . ' ' . $secondary_sort_order;	// User secondary sort
+			$sql .= "\n" . <<<SQL
+				, {$orderBy2} {$secondary_sort_order}
+				SQL;	// User secondary sort
 		}
 		if ($sort !== 'id') {
 			// For keyset pagination
-			$sql .= ', e0.id ' . $order;
+			$sql .= "\n" . <<<SQL
+				, e0.id {$order}
+				SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false) {
@@ -1731,14 +1796,14 @@ SQL;
 		$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
 		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
 		$hash = static::sqlHexEncode('hash');
-		$repeats = str_repeat('?,', count($ids) - 1) . '?';
+		$idPlaceholders = str_repeat('?,', count($ids) - 1) . '?';
 		$sql = <<<SQL
-SELECT id, guid, title, author, link, date, `lastModified`, `lastUserModified`,
-	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}
-FROM `_entry`
-WHERE id IN ({$repeats})
-ORDER BY id {$order}
-SQL;
+			SELECT id, guid, title, author, link, date, `lastModified`, `lastUserModified`,
+				{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}
+			FROM `_entry`
+			WHERE id IN ({$idPlaceholders})
+			ORDER BY id {$order}
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($stm === false || !$stm->execute($ids)) {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -1803,8 +1868,13 @@ SQL;
 			return $result;
 		}
 		$guids = array_unique($guids);
-		$sql = 'SELECT guid, ' . static::sqlHexEncode('hash') .
-			' AS hex_hash FROM `_entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)';
+		$hexHashExpr = static::sqlHexEncode('hash');
+		$guidPlaceholders = str_repeat('?,', count($guids) - 1) . '?';
+		$sql = <<<SQL
+			SELECT guid, {$hexHashExpr} AS hex_hash
+			FROM `_entry`
+			WHERE id_feed=? AND guid IN ({$guidPlaceholders})
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		$values = [$id_feed];
 		$values = array_merge($values, $guids);
@@ -1842,10 +1912,12 @@ SQL;
 
 		// Reduce MySQL deadlock probability by ensuring consistent lock ordering
 		$orderBy = $this->pdo->dbType() === 'mysql' ? ' ORDER BY id DESC' : '';
+		$guidPlaceholders = str_repeat('?,', count($guids) - 1) . '?';
 
-		$sql = 'UPDATE `_entry` ' .
-			'SET `lastSeen`=? WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)' .
-			$orderBy;
+		$sql = <<<SQL
+			UPDATE `_entry`
+			SET `lastSeen`=? WHERE id_feed=? AND guid IN ({$guidPlaceholders}){$orderBy}
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($mtime <= 0) {
 			$mtime = time();
@@ -1870,12 +1942,12 @@ SQL;
 	 */
 	public function updateLastSeenUnchanged(int $id_feed, int $mtime = 0): int|false {
 		$sql = <<<'SQL'
-UPDATE `_entry` SET `lastSeen` = :mtime
-WHERE id_feed = :id_feed1 AND `lastSeen` = (
-	SELECT `lastUpdate` FROM `_feed` f
-	WHERE f.id = :id_feed2
-)
-SQL;
+			UPDATE `_entry` SET `lastSeen` = :mtime
+			WHERE id_feed = :id_feed1 AND `lastSeen` = (
+				SELECT `lastUpdate` FROM `_feed` f
+				WHERE f.id = :id_feed2
+			)
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($mtime <= 0) {
 			$mtime = time();
@@ -1904,10 +1976,10 @@ SQL;
 			FROM `_entry` e
 			SQL;
 		if ($minPriority !== null) {
-			$sql .= <<<'SQL'
-			INNER JOIN `_feed` f ON e.id_feed = f.id
-			WHERE f.priority > :priority
-			SQL;
+			$sql .= "\n" . <<<'SQL'
+				INNER JOIN `_feed` f ON e.id_feed = f.id
+				WHERE f.priority > :priority
+				SQL;
 			$values[':priority'] = $minPriority;
 		}
 		$res = $this->fetchAssoc($sql, $values);
@@ -1921,15 +1993,18 @@ SQL;
 	}
 
 	public function count(?int $minPriority = null): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entry` e';
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_entry` e
+			SQL;
 		$values = [];
 		if ($minPriority !== null) {
-			$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
-			$sql .= ' WHERE f.priority > :priority';
+			$sql .= "\n" . <<<'SQL'
+				INNER JOIN `_feed` f ON e.id_feed=f.id
+				WHERE f.priority > :priority
+				SQL;
 			$values[':priority'] = $minPriority;
 		}
-		$res = $this->fetchColumn($sql, 0, $values);
-		return isset($res[0]) ? (int)($res[0]) : -1;
+		return $this->fetchInt($sql, $values) ?? -1;
 	}
 
 	/** @return array{'all':int,'read':int,'unread':int} */

+ 25 - 12
app/Models/EntryDAOSQLite.php

@@ -63,7 +63,8 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	/** @param array{0:string,1:int,2:string} $errorInfo */
 	#[\Override]
 	protected function autoUpdateDb(array $errorInfo): bool {
-		if (($tableInfo = $this->pdo->query("PRAGMA table_info('entry')")) !== false && ($columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1)) !== false) {
+		$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);
@@ -124,10 +125,17 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		} else {
 			FreshRSS_UserDAO::touch();
 			$this->pdo->beginTransaction();
-			$sql = 'UPDATE `_entry` SET is_read=?, `lastUserModified` = ? WHERE id=? AND is_read=?';
-			$values = [$is_read ? 1 : 0, time(), $ids, $is_read ? 0 : 1];
+			$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->execute($values)) {
+			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)) {
@@ -140,11 +148,15 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			}
 			$affected = $stm->rowCount();
 			if ($affected > 0) {
-				$sql = 'UPDATE `_feed` SET `cache_nbUnreads`=`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
-				 . 'WHERE id=(SELECT e.id_feed FROM `_entry` e WHERE e.id=?)';
-				$values = [$ids];
+				$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->execute($values)) {
+				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();
@@ -170,10 +182,11 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
 		}
 
-		$sql = 'UPDATE `_entry` SET is_read = ?, `lastUserModified` = ? WHERE is_read <> ? AND id <= ? AND '
-			 . 'id IN (SELECT et.id_entry FROM `_entrytag` et '
-			 . ($id == 0 ? '' : 'WHERE et.id_tag = ?')
-			 . ')';
+		$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;

+ 181 - 113
app/Models/FeedDAO.php

@@ -44,19 +44,17 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	public function addFeed(array $valuesTmp): int|false {
 		if (empty($valuesTmp['id'])) {	// Auto-generated ID
 			$sql = <<<'SQL'
-INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-SQL;
+				INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
+				VALUES (:url, :kind, :category, :name, :website, :description, :last_update, :priority, :path_entries, :http_auth, :error, :ttl, :attributes)
+				SQL;
 		} else {
 			$sql = <<<'SQL'
-INSERT INTO `_feed` (id, url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-SQL;
+				INSERT INTO `_feed` (id, url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
+				VALUES (:id, :url, :kind, :category, :name, :website, :description, :last_update, :priority, :path_entries, :http_auth, :error, :ttl, :attributes)
+				SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
 
-		$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
-		$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
 		if (!isset($valuesTmp['pathEntries'])) {
 			$valuesTmp['pathEntries'] = '';
 		}
@@ -64,24 +62,27 @@ SQL;
 			$valuesTmp['attributes'] = [];
 		}
 
-		$values = empty($valuesTmp['id']) ? [] : [$valuesTmp['id']];
-		$values = array_merge($values, [
-			$valuesTmp['url'],
-			$valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS,
-			$valuesTmp['category'],
-			mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
-			$valuesTmp['website'],
-			FreshRSS_SimplePieCustom::sanitizeHTML($valuesTmp['description'], ''),
-			$valuesTmp['lastUpdate'],
-			isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM,
-			mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'),
-			base64_encode($valuesTmp['httpAuth'] ?? ''),
-			isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0,
-			isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT,
-			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
-		]);
-
-		if ($stm !== false && $stm->execute($values)) {
+		$ok = $stm !== false;
+		if ($ok) {
+			if (!empty($valuesTmp['id'])) {
+				$ok &= $stm->bindValue(':id', (int)$valuesTmp['id'], PDO::PARAM_INT);
+			}
+			$ok &= $stm->bindValue(':url', safe_ascii($valuesTmp['url']), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':kind', $valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS, PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':category', $valuesTmp['category'], PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':name', mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':website', safe_ascii($valuesTmp['website']), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':description', FreshRSS_SimplePieCustom::sanitizeHTML($valuesTmp['description'], ''), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':last_update', $valuesTmp['lastUpdate'], PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':priority', isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM, PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':path_entries', mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':http_auth', base64_encode($valuesTmp['httpAuth'] ?? ''), PDO::PARAM_STR);
+			$ok &= $stm->bindValue(':error', isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0, PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':ttl', isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT, PDO::PARAM_INT);
+			$ok &= $stm->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR);
+		}
+		if ($ok && $stm !== false && $stm->execute()) {
 			if (empty($valuesTmp['id'])) {
 				// Auto-generated ID
 				$feedId = $this->pdo->lastInsertId('`_feed_id_seq`');
@@ -183,12 +184,14 @@ SQL;
 			if ($key === 'httpAuth') {
 				$valuesTmp[$key] = is_string($v) ? base64_encode($v) : '';
 			} elseif ($key === 'attributes') {
-				$valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES);
+				$valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
 			}
 		}
 		$set = substr($set, 0, -2);
 
-		$sql = 'UPDATE `_feed` SET ' . $set . ' WHERE id=?';
+		$sql = <<<SQL
+			UPDATE `_feed` SET {$set} WHERE id=?
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 
 		foreach ($valuesTmp as $v) {
@@ -225,15 +228,15 @@ SQL;
 	 * @see updateCachedValues()
 	 */
 	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0): int|false {
-		$sql = 'UPDATE `_feed` SET `lastUpdate`=?, error=? WHERE id=?';
-		$values = [
-			$mtime <= 0 ? time() : $mtime,
-			$inError ? 1 : 0,
-			$id,
-		];
+		$sql = <<<'SQL'
+			UPDATE `_feed` SET `lastUpdate`=:last_update, error=:error WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':last_update', $mtime <= 0 ? time() : $mtime, PDO::PARAM_INT) &&
+			$stm->bindValue(':error', $inError ? 1 : 0, PDO::PARAM_INT) &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -243,8 +246,21 @@ SQL;
 	}
 
 	public function mute(int $id, bool $value = true): int|false {
-		$sql = 'UPDATE `_feed` SET ttl=' . ($value ? '-' : '') . 'ABS(ttl) WHERE id=' . intval($id);
-		return $this->pdo->exec($sql);
+		$sign = $value ? '-' : '';
+		$sql = <<<SQL
+			UPDATE `_feed`
+			SET ttl = {$sign}ABS(ttl)
+			WHERE id = :id
+			SQL;
+		$stm = $this->pdo->prepare($sql);
+		if ($stm !== false &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
+			return $stm->rowCount();
+		}
+		$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
 	}
 
 	public function changeCategory(int $idOldCat, int $idNewCat): int|false {
@@ -257,15 +273,14 @@ SQL;
 			return false;
 		}
 
-		$sql = 'UPDATE `_feed` SET category=? WHERE category=?';
+		$sql = <<<'SQL'
+			UPDATE `_feed` SET category=:new_category WHERE category=:old_category
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		$values = [
-			$newCat->id(),
-			$idOldCat,
-		];
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':new_category', $newCat->id(), PDO::PARAM_INT) &&
+			$stm->bindValue(':old_category', $idOldCat, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -275,12 +290,13 @@ SQL;
 	}
 
 	public function deleteFeed(int $id): int|false {
-		$sql = 'DELETE FROM `_feed` WHERE id=?';
+		$sql = <<<'SQL'
+			DELETE FROM `_feed` WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		$values = [$id];
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -294,18 +310,23 @@ SQL;
 	 * @param bool|null $errored to include only errored feeds
 	 */
 	public function deleteFeedByCategory(int $id, ?bool $muted = null, ?bool $errored = null): int|false {
-		$sql = 'DELETE FROM `_feed` WHERE category=?';
+		$sql = <<<'SQL'
+			DELETE FROM `_feed` WHERE category=:category
+			SQL;
 		if ($muted) {
-			$sql .= ' AND ttl < 0';
+			$sql .= "\n" . <<<'SQL'
+				AND ttl < 0
+				SQL;
 		}
 		if ($errored) {
-			$sql .= ' AND error <> 0';
+			$sql .= "\n" . <<<'SQL'
+				AND error <> 0
+				SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
-
-		$values = [$id];
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':category', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -318,10 +339,10 @@ SQL;
 	 * 	pathEntries?:string,httpAuth?:string,error:int|bool,ttl?:int,attributes?:string}> */
 	public function selectAll(): Traversable {
 		$sql = <<<'SQL'
-SELECT id, url, kind, category, name, website, description, `lastUpdate`,
-	priority, `pathEntries`, `httpAuth`, error, ttl, attributes
-FROM `_feed`
-SQL;
+			SELECT id, url, kind, category, name, website, description, `lastUpdate`,
+				priority, `pathEntries`, `httpAuth`, error, ttl, attributes
+			FROM `_feed`
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
@@ -341,7 +362,9 @@ SQL;
 	}
 
 	public function searchById(int $id): ?FreshRSS_Feed {
-		$sql = 'SELECT * FROM `_feed` WHERE id=:id';
+		$sql = <<<'SQL'
+			SELECT * FROM `_feed` WHERE id=:id
+			SQL;
 		$res = $this->fetchAssoc($sql, [':id' => $id]);
 		if (!is_array($res)) {
 			return null;
@@ -353,7 +376,9 @@ SQL;
 	}
 
 	public function searchByUrl(string $url): ?FreshRSS_Feed {
-		$sql = 'SELECT * FROM `_feed` WHERE url=:url';
+		$sql = <<<'SQL'
+			SELECT * FROM `_feed` WHERE url=:url
+			SQL;
 		$res = $this->fetchAssoc($sql, [':url' => $url]);
 		if (!is_array($res)) {
 			return null;
@@ -365,7 +390,9 @@ SQL;
 
 	/** @return list<int> */
 	public function listFeedsIds(): array {
-		$sql = 'SELECT id FROM `_feed`';
+		$sql = <<<'SQL'
+			SELECT id FROM `_feed`
+			SQL;
 		/** @var list<int> $res */
 		$res = $this->fetchColumn($sql, 0) ?? [];
 		return $res;
@@ -373,7 +400,9 @@ SQL;
 
 	/** @return array<int,FreshRSS_Feed> where the key is the feed ID */
 	public function listFeeds(): array {
-		$sql = 'SELECT * FROM `_feed` ORDER BY name';
+		$sql = <<<'SQL'
+			SELECT * FROM `_feed` ORDER BY name
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		if (!is_array($res)) {
 			return [];
@@ -385,11 +414,17 @@ SQL;
 
 	/** @return array<string,string> */
 	public function listFeedsNewestItemUsec(?int $id_feed = null): array {
-		$sql = 'SELECT id_feed, MAX(id) as newest_item_us FROM `_entry` ';
+		$sql = <<<'SQL'
+			SELECT id_feed, MAX(id) as newest_item_us FROM `_entry`
+			SQL;
 		if ($id_feed === null) {
-			$sql .= 'GROUP BY id_feed';
+			$sql .= "\n" . <<<'SQL'
+				GROUP BY id_feed
+				SQL;
 		} else {
-			$sql .= 'WHERE id_feed=' . intval($id_feed);
+			$sql .= "\n" . <<<SQL
+				WHERE id_feed=$id_feed
+				SQL;
 		}
 		$res = $this->fetchAssoc($sql);
 		/** @var list<array{id_feed:int,newest_item_us:string}>|null $res */
@@ -408,12 +443,26 @@ SQL;
 	 * @return array<int,FreshRSS_Feed> where the key is the feed ID
 	 */
 	public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
-		$sql = 'SELECT * FROM `_feed` '
-			. ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
-				. ' AND `lastUpdate` < (' . (time() + 60)
-				. '-(CASE WHEN ttl=' . FreshRSS_Feed::TTL_DEFAULT . ' THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
-			. 'ORDER BY `lastUpdate` '
-			. ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
+		$ttlDefault = FreshRSS_Feed::TTL_DEFAULT;
+		$refreshThreshold = time() + 60;
+
+		$sql = <<<SQL
+			SELECT * FROM `_feed`
+			SQL;
+		if ($defaultCacheDuration >= 0) {
+			$sql .= "\n" . <<<SQL
+				WHERE ttl >= {$ttlDefault}
+				AND `lastUpdate` < ({$refreshThreshold}-(CASE WHEN ttl={$ttlDefault} THEN {$defaultCacheDuration} ELSE ttl END))
+				SQL;
+		}
+		$sql .= "\n" . <<<SQL
+			ORDER BY `lastUpdate`
+			SQL;
+		if ($limit > 0) {
+			$sql .= "\n" . <<<SQL
+				LIMIT {$limit}
+				SQL;
+		}
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
 			/** @var list<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
@@ -432,8 +481,14 @@ SQL;
 
 	/** @return list<string> */
 	public function listTitles(int $id, int $limit = 0): array {
-		$sql = 'SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC'
-			. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
+		$sql = <<<SQL
+			SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC
+			SQL;
+		if ($limit > 0) {
+			$sql .= "\n" . <<<SQL
+				LIMIT {$limit}
+				SQL;
+		}
 		$res = $this->fetchColumn($sql, 0, [':id_feed' => $id]) ?? [];
 		/** @var list<string> $res */
 		return $res;
@@ -445,12 +500,18 @@ SQL;
 	 * @return array<int,FreshRSS_Feed> where the key is the feed ID
 	 */
 	public function listByCategory(int $cat, ?bool $muted = null, ?bool $errored = null): array {
-		$sql = 'SELECT * FROM `_feed` WHERE category=:category';
+		$sql = <<<'SQL'
+			SELECT * FROM `_feed` WHERE category=:category
+			SQL;
 		if ($muted) {
-			$sql .= ' AND ttl < 0';
+			$sql .= "\n" . <<<SQL
+				AND ttl < 0
+				SQL;
 		}
 		if ($errored) {
-			$sql .= ' AND error <> 0';
+			$sql .= "\n" . <<<SQL
+				AND error <> 0
+				SQL;
 		}
 		$res = $this->fetchAssoc($sql, [':category' => $cat]);
 		if (!is_array($res)) {
@@ -464,15 +525,17 @@ SQL;
 	}
 
 	public function countEntries(int $id): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed';
-		$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
-		return isset($res[0]) ? (int)($res[0]) : -1;
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed
+			SQL;
+		return $this->fetchInt($sql, ['id_feed' => $id]) ?? -1;
 	}
 
 	public function countNotRead(int $id): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0';
-		$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
-		return isset($res[0]) ? (int)($res[0]) : -1;
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0
+			SQL;
+		return $this->fetchInt($sql, ['id_feed' => $id]) ?? -1;
 	}
 
 	/**
@@ -518,19 +581,19 @@ SQL;
 	public function markAsReadMaxUnread(int $id, int $n): int|false {
 		//Double SELECT for MySQL workaround ERROR 1093 (HY000)
 		$sql = <<<'SQL'
-UPDATE `_entry` SET is_read=1
-WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
-	SELECT e2.id FROM `_entry` e2
-	WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
-	ORDER BY e2.id DESC
-	LIMIT 1
-	OFFSET :limit) e3)
-SQL;
+			UPDATE `_entry` SET is_read=1
+			WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
+				SELECT e2.id FROM `_entry` e2
+				WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
+				ORDER BY e2.id DESC
+				LIMIT 1
+				OFFSET :limit) e3)
+			SQL;
 
 		if (($stm = $this->pdo->prepare($sql)) !== false &&
-			$stm->bindParam(':id_feed1', $id, PDO::PARAM_INT) &&
-			$stm->bindParam(':id_feed2', $id, PDO::PARAM_INT) &&
-			$stm->bindParam(':limit', $n, PDO::PARAM_INT) &&
+			$stm->bindValue(':id_feed1', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':id_feed2', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':limit', $n, PDO::PARAM_INT) &&
 			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
@@ -546,9 +609,9 @@ SQL;
 	 */
 	public function markAsReadNotSeen(int $id, int $minLastSeen): int|false {
 		$sql = <<<'SQL'
-UPDATE `_entry` SET is_read=1
-WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
-SQL;
+			UPDATE `_entry` SET is_read=1
+			WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
+			SQL;
 
 		if (($stm = $this->pdo->prepare($sql)) !== false &&
 			$stm->bindValue(':id_feed', $id, PDO::PARAM_INT) &&
@@ -563,11 +626,13 @@ SQL;
 	}
 
 	public function truncate(int $id): int|false {
-		$sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
+		$sql = <<<'SQL'
+			DELETE FROM `_entry` WHERE id_feed=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		$this->pdo->beginTransaction();
 		if (!($stm !== false &&
-			$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
 			$stm->execute())) {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
@@ -576,10 +641,12 @@ SQL;
 		}
 		$affected = $stm->rowCount();
 
-		$sql = 'UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id';
+		$sql = <<<'SQL'
+			UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if (!($stm !== false &&
-			$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
 			$stm->execute())) {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
@@ -592,7 +659,9 @@ SQL;
 	}
 
 	public function purge(): bool {
-		$sql = 'DELETE FROM `_entry`';
+		$sql = <<<'SQL'
+			DELETE FROM `_entry`
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		$this->pdo->beginTransaction();
 		if ($stm === false || !$stm->execute()) {
@@ -602,7 +671,9 @@ SQL;
 			return false;
 		}
 
-		$sql = 'UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0';
+		$sql = <<<'SQL'
+			UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($stm === false || !$stm->execute()) {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -657,12 +728,9 @@ SQL;
 	}
 
 	public function count(): int {
-		$sql = 'SELECT COUNT(e.id) AS count FROM `_feed` e';
-		$stm = $this->pdo->query($sql);
-		if ($stm === false) {
-			return -1;
-		}
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return is_numeric($res[0] ?? null) ? (int)$res[0] : 0;
+		$sql = <<<'SQL'
+			SELECT COUNT(e.id) AS count FROM `_feed` e
+			SQL;
+		return $this->fetchInt($sql) ?? -1;
 	}
 }

+ 2 - 2
app/Models/FeedDAOPGSQL.php

@@ -6,8 +6,8 @@ class FreshRSS_FeedDAOPGSQL extends FreshRSS_FeedDAO {
 	#[\Override]
 	public function sqlResetSequence(): bool {
 		$sql = <<<'SQL'
-SELECT setval('`_feed_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_feed`
-SQL;
+			SELECT setval('`_feed_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_feed`
+			SQL;
 		return $this->pdo->exec($sql) !== false;
 	}
 

+ 2 - 2
app/Models/FeedDAOSQLite.php

@@ -11,8 +11,8 @@ class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAOPGSQL {
 	/** @param array{0:string,1:int,2:string} $errorInfo */
 	#[\Override]
 	public function autoUpdateDb(array $errorInfo): bool {
-		if (($tableInfo = $this->pdo->query("PRAGMA table_info('feed')")) !== false) {
-			$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
+		$columns = $this->fetchColumn("PRAGMA table_info('feed')", 1);
+		if ($columns !== null) {
 			foreach (['kind'] as $column) {
 				if (!in_array($column, $columns, true)) {
 					return $this->addColumn($column);

+ 68 - 79
app/Models/StatsDAO.php

@@ -64,14 +64,14 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 			$filter .= "AND e.id_feed = {$feed}";
 		}
 		$sql = <<<SQL
-SELECT COUNT(1) AS total,
-COUNT(1) - SUM(e.is_read) AS count_unreads,
-SUM(e.is_read) AS count_reads,
-SUM(e.is_favorite) AS count_favorites
-FROM `_entry` AS e, `_feed` AS f
-WHERE e.id_feed = f.id
-{$filter}
-SQL;
+			SELECT COUNT(1) AS total,
+			COUNT(1) - SUM(e.is_read) AS count_unreads,
+			SUM(e.is_read) AS count_reads,
+			SUM(e.is_favorite) AS count_favorites
+			FROM `_entry` AS e, `_feed` AS f
+			WHERE e.id_feed = f.id
+			{$filter}
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		if (is_array($res) && !empty($res[0]) && is_array($res[0])) {
 			$dao = array_map('intval', $res[0]);
@@ -93,13 +93,13 @@ SQL;
 		// Get stats per day for the last 30 days
 		$sqlDay = $this->sqlFloor("(date - $midnight) / 86400");
 		$sql = <<<SQL
-SELECT {$sqlDay} AS day,
-COUNT(*) as count
-FROM `_entry`
-WHERE date >= {$oldest} AND date < {$midnight}
-GROUP BY day
-ORDER BY day ASC
-SQL;
+			SELECT {$sqlDay} AS day,
+			COUNT(*) as count
+			FROM `_entry`
+			WHERE date >= {$oldest} AND date < {$midnight}
+			GROUP BY day
+			ORDER BY day ASC
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		if (!is_array($res)) {
 			return [];
@@ -158,16 +158,15 @@ SQL;
 		}
 		$offset = $this->getTimezoneOffset();
 		$sql = <<<SQL
-SELECT DATE_FORMAT(FROM_UNIXTIME(e.date + {$offset}), '{$period}') AS period
-, COUNT(1) AS count
-FROM `_entry` AS e
-{$restrict}
-GROUP BY period
-ORDER BY period ASC
-SQL;
+			SELECT DATE_FORMAT(FROM_UNIXTIME(e.date + {$offset}), '{$period}') AS period, COUNT(1) AS count
+			FROM `_entry` AS e
+			{$restrict}
+			GROUP BY period
+			ORDER BY period ASC
+			SQL;
 
 		$res = $this->fetchAssoc($sql);
-		if ($res == false) {
+		if (empty($res)) {
 			return [];
 		}
 		$periodMax = match ($period) {
@@ -216,12 +215,10 @@ SQL;
 			$restrict = "WHERE e.id_feed = {$feed}";
 		}
 		$sql = <<<SQL
-SELECT COUNT(1) AS count
-, MIN(date) AS date_min
-, MAX(date) AS date_max
-FROM `_entry` AS e
-{$restrict}
-SQL;
+			SELECT COUNT(1) AS count, MIN(date) AS date_min, MAX(date) AS date_max
+			FROM `_entry` AS e
+			{$restrict}
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		if ($res == null || empty($res[0])) {
 			return -1.0;
@@ -254,15 +251,14 @@ SQL;
 	 * @return list<array{'label':string,'data':int}>
 	 */
 	public function calculateFeedByCategory(): array {
-		$sql = <<<SQL
-SELECT c.name AS label
-, COUNT(f.id) AS data
-FROM `_category` AS c, `_feed` AS f
-WHERE c.id = f.category
-GROUP BY label
-ORDER BY data DESC
-SQL;
-		/** @var list<array{'label':string,'data':int}>|null @res */
+		$sql = <<<'SQL'
+			SELECT c.name AS label, COUNT(f.id) AS data
+			FROM `_category` AS c, `_feed` AS f
+			WHERE c.id = f.category
+			GROUP BY label
+			ORDER BY data DESC
+			SQL;
+		/** @var list<array{'label':string,'data':int}>|null $res */
 		$res = $this->fetchAssoc($sql);
 		return $res == null ? [] : $res;
 	}
@@ -272,15 +268,14 @@ SQL;
 	 * @return list<array{'label':string,'data':int}>
 	 */
 	public function calculateEntryByCategory(): array {
-		$sql = <<<SQL
-SELECT c.name AS label
-, COUNT(e.id) AS data
-FROM `_category` AS c, `_feed` AS f, `_entry` AS e
-WHERE c.id = f.category
-AND f.id = e.id_feed
-GROUP BY label
-ORDER BY data DESC
-SQL;
+		$sql = <<<'SQL'
+			SELECT c.name AS label, COUNT(e.id) AS data
+			FROM `_category` AS c, `_feed` AS f, `_entry` AS e
+			WHERE c.id = f.category
+			AND f.id = e.id_feed
+			GROUP BY label
+			ORDER BY data DESC
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		/** @var list<array{'label':string,'data':int}>|null $res */
 		return $res == null ? [] : $res;
@@ -291,18 +286,15 @@ SQL;
 	 * @return list<array{'id':int,'name':string,'category':string,'count':int}>
 	 */
 	public function calculateTopFeed(): array {
-		$sql = <<<SQL
-SELECT f.id AS id
-, MAX(f.name) AS name
-, MAX(c.name) AS category
-, COUNT(e.id) AS count
-FROM `_category` AS c, `_feed` AS f, `_entry` AS e
-WHERE c.id = f.category
-AND f.id = e.id_feed
-GROUP BY f.id
-ORDER BY count DESC
-LIMIT 10
-SQL;
+		$sql = <<<'SQL'
+			SELECT f.id AS id, MAX(f.name) AS name, MAX(c.name) AS category, COUNT(e.id) AS count
+			FROM `_category` AS c, `_feed` AS f, `_entry` AS e
+			WHERE c.id = f.category
+			AND f.id = e.id_feed
+			GROUP BY f.id
+			ORDER BY count DESC
+			LIMIT 10
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		/** @var list<array{'id':int,'name':string,'category':string,'count':int}>|null $res */
 		if (is_array($res)) {
@@ -316,16 +308,13 @@ SQL;
 	 * @return list<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>
 	 */
 	public function calculateFeedLastDate(): array {
-		$sql = <<<SQL
-SELECT MAX(f.id) as id
-, MAX(f.name) AS name
-, MAX(date) AS last_date
-, COUNT(*) AS nb_articles
-FROM `_feed` AS f, `_entry` AS e
-WHERE f.id = e.id_feed
-GROUP BY f.id
-ORDER BY name
-SQL;
+		$sql = <<<'SQL'
+			SELECT MAX(f.id) as id, MAX(f.name) AS name, MAX(date) AS last_date, COUNT(*) AS nb_articles
+			FROM `_feed` AS f, `_entry` AS e
+			WHERE f.id = e.id_feed
+			GROUP BY f.id
+			ORDER BY name
+			SQL;
 		$res = $this->fetchAssoc($sql);
 		/** @var list<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>|null $res */
 		if (is_array($res)) {
@@ -390,16 +379,16 @@ SQL;
 	 */
 	public function getMaxUnreadDates(string $field, string $granularity, int $max = 100, int $minPriority = FreshRSS_Feed::PRIORITY_HIDDEN): array {
 		$sql = <<<SQL
-		SELECT
-			{$this->sqlDateToIsoGranularity('e.' . $field, precision: $field === 'id' ? 1000000 : 1, granularity: $granularity)} AS granularity,
-			COUNT(*) AS unread_count
-		FROM `_entry` e
-		INNER JOIN `_feed` f ON e.id_feed = f.id
-		WHERE e.is_read = 0 AND f.priority >= :min_priority
-		GROUP BY granularity
-		ORDER BY unread_count DESC, granularity DESC
-		LIMIT :max
-		SQL;
+			SELECT
+				{$this->sqlDateToIsoGranularity('e.' . $field, precision: $field === 'id' ? 1000000 : 1, granularity: $granularity)} AS granularity,
+				COUNT(*) AS unread_count
+			FROM `_entry` e
+			INNER JOIN `_feed` f ON e.id_feed = f.id
+			WHERE e.is_read = 0 AND f.priority >= :min_priority
+			GROUP BY granularity
+			ORDER BY unread_count DESC, granularity DESC
+			LIMIT :max
+			SQL;
 		if (($stm = $this->pdo->prepare($sql)) !== false &&
 			$stm->bindValue(':min_priority', $minPriority, PDO::PARAM_INT) &&
 			$stm->bindValue(':max', $max, PDO::PARAM_INT) &&

+ 6 - 7
app/Models/StatsDAOPGSQL.php

@@ -59,13 +59,12 @@ class FreshRSS_StatsDAOPGSQL extends FreshRSS_StatsDAO {
 		}
 		$offset = $this->getTimezoneOffset();
 		$sql = <<<SQL
-SELECT extract( {$period} from to_timestamp(e.date + {$offset})) AS period
-, COUNT(1) AS count
-FROM `_entry` AS e
-{$restrict}
-GROUP BY period
-ORDER BY period ASC
-SQL;
+			SELECT extract( {$period} from to_timestamp(e.date + {$offset})) AS period, COUNT(1) AS count
+			FROM `_entry` AS e
+			{$restrict}
+			GROUP BY period
+			ORDER BY period ASC
+			SQL;
 
 		$res = $this->fetchAssoc($sql);
 		if ($res == null) {

+ 6 - 7
app/Models/StatsDAOSQLite.php

@@ -34,13 +34,12 @@ class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {
 		}
 		$offset = $this->getTimezoneOffset();
 		$sql = <<<SQL
-SELECT strftime('{$period}', e.date + {$offset}, 'unixepoch') AS period
-, COUNT(1) AS count
-FROM `_entry` AS e
-{$restrict}
-GROUP BY period
-ORDER BY period ASC
-SQL;
+			SELECT strftime('{$period}', e.date + {$offset}, 'unixepoch') AS period, COUNT(1) AS count
+			FROM `_entry` AS e
+			{$restrict}
+			GROUP BY period
+			ORDER BY period ASC
+			SQL;
 
 		$res = $this->fetchAssoc($sql);
 		if ($res == null) {

+ 118 - 102
app/Models/TagDAO.php

@@ -17,35 +17,33 @@ class FreshRSS_TagDAO extends Minz_ModelPdo {
 	public function addTag(array $valuesTmp): int|false {
 		if (empty($valuesTmp['id'])) {	// Auto-generated ID
 			$sql = <<<'SQL'
-INSERT INTO `_tag`(name, attributes)
-SELECT * FROM (SELECT :name1 AS name, :attributes AS attributes) t2
-SQL;
+				INSERT INTO `_tag`(name, attributes)
+				SELECT * FROM (SELECT :name1 AS name, :attributes AS attributes) t2
+				SQL;
 		} else {
 			$sql = <<<'SQL'
-INSERT INTO `_tag`(id, name, attributes)
-SELECT * FROM (SELECT 1*:id AS id, :name1 AS name, :attributes AS attributes) t2
-SQL;
+				INSERT INTO `_tag`(id, name, attributes)
+				SELECT * FROM (SELECT 1*:id AS id, :name1 AS name, :attributes AS attributes) t2
+				SQL;
 		}
 		// No category of the same name
 		$sql .= "\n" . <<<'SQL'
-WHERE NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
-SQL;
+			WHERE NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 
 		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		if (!isset($valuesTmp['attributes'])) {
 			$valuesTmp['attributes'] = [];
 		}
-		if ($stm !== false) {
-			if (!empty($valuesTmp['id'])) {
-				$stm->bindValue(':id', $valuesTmp['id'], PDO::PARAM_INT);
-			}
-			$stm->bindValue(':name1', $valuesTmp['name'], PDO::PARAM_STR);
-			$stm->bindValue(':name2', $valuesTmp['name'], PDO::PARAM_STR);
+
+		if ($stm !== false &&
+			(empty($valuesTmp['id']) || $stm->bindValue(':id', $valuesTmp['id'], PDO::PARAM_INT)) &&
+			$stm->bindValue(':name1', $valuesTmp['name'], PDO::PARAM_STR) &&
+			$stm->bindValue(':name2', $valuesTmp['name'], PDO::PARAM_STR) &&
 			$stm->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
-				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR);
-		}
-		if ($stm !== false && $stm->execute() && $stm->rowCount() > 0) {
+				json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR) &&
+			$stm->execute() && $stm->rowCount() > 0) {
 			if (empty($valuesTmp['id'])) {
 				// Auto-generated ID
 				$tagId = $this->pdo->lastInsertId('`_tag_id_seq`');
@@ -75,9 +73,9 @@ SQL;
 	public function updateTagName(int $id, string $name): int|false {
 		// No category of the same name
 		$sql = <<<'SQL'
-UPDATE `_tag` SET name = :name1 WHERE id = :id
-AND NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
-SQL;
+			UPDATE `_tag` SET name = :name1 WHERE id = :id
+			AND NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
+			SQL;
 
 		$name = mb_strcut(trim($name), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		$stm = $this->pdo->prepare($sql);
@@ -98,7 +96,9 @@ SQL;
 	 * @param array<string,mixed> $attributes
 	 */
 	public function updateTagAttributes(int $id, array $attributes): int|false {
-		$sql = 'UPDATE `_tag` SET attributes=:attributes WHERE id=:id';
+		$sql = <<<'SQL'
+			UPDATE `_tag` SET attributes=:attributes WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false &&
 			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
@@ -123,12 +123,13 @@ SQL;
 		if ($id <= 0) {
 			return false;
 		}
-		$sql = 'DELETE FROM `_tag` WHERE id=?';
+		$sql = <<<'SQL'
+			DELETE FROM `_tag` WHERE id=:id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		$values = [$id];
-
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -139,7 +140,9 @@ SQL;
 
 	/** @return Traversable<array{id:int,name:string,attributes?:array<string,mixed>}> */
 	public function selectAll(): Traversable {
-		$sql = 'SELECT id, name, attributes FROM `_tag`';
+		$sql = <<<'SQL'
+			SELECT id, name, attributes FROM `_tag`
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm === false) {
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
@@ -153,7 +156,9 @@ SQL;
 
 	/** @return Traversable<array{id_tag:int,id_entry:int|numeric-string}> */
 	public function selectEntryTag(): Traversable {
-		$sql = 'SELECT id_tag, id_entry FROM `_entrytag`';
+		$sql = <<<'SQL'
+			SELECT id_tag, id_entry FROM `_entrytag`
+			SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm === false) {
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
@@ -167,22 +172,28 @@ SQL;
 
 	public function updateEntryTag(int $oldTagId, int $newTagId): int|false {
 		$sql = <<<'SQL'
-DELETE FROM `_entrytag` WHERE EXISTS (
-	SELECT 1 FROM `_entrytag` AS e
-	WHERE e.id_entry = `_entrytag`.id_entry AND e.id_tag = ? AND `_entrytag`.id_tag = ?)
-SQL;
+			DELETE FROM `_entrytag` WHERE EXISTS (
+				SELECT 1 FROM `_entrytag` AS e
+				WHERE e.id_entry = `_entrytag`.id_entry AND e.id_tag = :new_tag_id AND `_entrytag`.id_tag = :old_tag_id)
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		if ($stm === false || !$stm->execute([$newTagId, $oldTagId])) {
+		if ($stm === false ||
+			!$stm->bindValue(':new_tag_id', $newTagId, PDO::PARAM_INT) ||
+			!$stm->bindValue(':old_tag_id', $oldTagId, PDO::PARAM_INT) ||
+			!$stm->execute()) {
 			$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
 			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 			return false;
 		}
 
-		$sql = 'UPDATE `_entrytag` SET id_tag = ? WHERE id_tag = ?';
+		$sql = <<<'SQL'
+			UPDATE `_entrytag` SET id_tag = :new_tag_id WHERE id_tag = :old_tag_id
+			SQL;
 		$stm = $this->pdo->prepare($sql);
-
-		if ($stm !== false && $stm->execute([$newTagId, $oldTagId])) {
+		if ($stm !== false &&
+			$stm->bindValue(':new_tag_id', $newTagId, PDO::PARAM_INT) &&
+			$stm->bindValue(':old_tag_id', $oldTagId, PDO::PARAM_INT) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		}
 		$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -206,19 +217,21 @@ SQL;
 	public function listTags(bool $precounts = false): array {
 		if ($precounts) {
 			$sql = <<<'SQL'
-SELECT t.id, t.name, count(e.id) AS unreads
-FROM `_tag` t
-LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
-LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id AND e.is_read = 0
-GROUP BY t.id
-ORDER BY t.name
-SQL;
+				SELECT t.id, t.name, count(e.id) AS unreads
+				FROM `_tag` t
+				LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
+				LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id AND e.is_read = 0
+				GROUP BY t.id
+				ORDER BY t.name
+				SQL;
 		} else {
-			$sql = 'SELECT * FROM `_tag` ORDER BY name';
+			$sql = <<<'SQL'
+				SELECT * FROM `_tag` ORDER BY name
+				SQL;
 		}
 
-		$stm = $this->pdo->query($sql);
-		if ($stm !== false && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
+		$res = $this->fetchAssoc($sql);
+		if ($res !== null) {
 			/** @var list<array{id:int,name:string,unreads:int}> $res */
 			return self::daoToTags($res);
 		} else {
@@ -231,15 +244,19 @@ SQL;
 	/** @return array<string,string> */
 	public function listTagsNewestItemUsec(?int $id_tag = null): array {
 		$sql = <<<'SQL'
-SELECT t.id AS id_tag, MAX(e.id) AS newest_item_us
-FROM `_tag` t
-LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
-LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id
-SQL;
+			SELECT t.id AS id_tag, MAX(e.id) AS newest_item_us
+			FROM `_tag` t
+			LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
+			LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id
+			SQL;
 		if ($id_tag === null) {
-			$sql .= ' GROUP BY t.id';
+			$sql .= "\n" . <<<'SQL'
+				GROUP BY t.id
+				SQL;
 		} else {
-			$sql .= ' WHERE t.id=' . $id_tag;
+			$sql .= "\n" . <<<SQL
+				WHERE t.id={$id_tag}
+				SQL;
 		}
 		$res = $this->fetchAssoc($sql);
 		if ($res == null) {
@@ -253,56 +270,52 @@ SQL;
 	}
 
 	public function count(): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_tag`';
-		$stm = $this->pdo->query($sql);
-		if ($stm !== false) {
-			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-			/** @var list<array{count:int|numeric-string}> $res */
-			return (int)$res[0]['count'];
-		}
-		$info = $this->pdo->errorInfo();
-		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
-		return -1;
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_tag`
+			SQL;
+		return $this->fetchInt($sql) ?? -1;
 	}
 
 	public function countEntries(int $id): int {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=:id_tag';
-		$res = $this->fetchAssoc($sql, [':id_tag' => $id]);
-		if ($res == null || !isset($res[0]['count'])) {
-			return -1;
-		}
-		return (int)$res[0]['count'];
+		$sql = <<<'SQL'
+			SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=:id_tag
+			SQL;
+		return $this->fetchInt($sql, [':id_tag' => $id]) ?? -1;
 	}
 
 	public function countNotRead(?int $id = null): int {
 		$sql = <<<'SQL'
-SELECT COUNT(*) AS count FROM `_entrytag` et
-INNER JOIN `_entry` e ON et.id_entry=e.id
-WHERE e.is_read=0
-SQL;
+			SELECT COUNT(*) AS count FROM `_entrytag` et
+			INNER JOIN `_entry` e ON et.id_entry=e.id
+			WHERE e.is_read=0
+			SQL;
 		$values = [];
 		if (null !== $id) {
-			$sql .= ' AND et.id_tag=:id_tag';
+			$sql .= "\n" . <<<'SQL'
+				AND et.id_tag=:id_tag
+				SQL;
 			$values[':id_tag'] = $id;
 		}
-
-		$res = $this->fetchAssoc($sql, $values);
-		if ($res == null || !isset($res[0]['count'])) {
-			return -1;
-		}
-		return (int)$res[0]['count'];
+		return $this->fetchInt($sql, $values) ?? -1;
 	}
 
 	public function tagEntry(int $id_tag, string $id_entry, bool $checked = true): bool {
 		if ($checked) {
-			$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES(?, ?)';
+			$ignore = $this->sqlIgnore();
+			$sql = <<<SQL
+				INSERT {$ignore} INTO `_entrytag`(id_tag, id_entry) VALUES(:id_tag, :id_entry)
+				SQL;
 		} else {
-			$sql = 'DELETE FROM `_entrytag` WHERE id_tag=? AND id_entry=?';
+			$sql = <<<'SQL'
+				DELETE FROM `_entrytag` WHERE id_tag=:id_tag AND id_entry=:id_entry
+				SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
-		$values = [$id_tag, $id_entry];
 
-		if ($stm !== false && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':id_tag', $id_tag, PDO::PARAM_INT) &&
+			$stm->bindValue(':id_entry', $id_entry, PDO::PARAM_STR) &&
+			$stm->execute()) {
 			return true;
 		}
 		$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -316,7 +329,10 @@ SQL;
 	 */
 	public function tagEntries(iterable $addLabels): int|false {
 		$hasValues = false;
-		$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES ';
+		$ignore = $this->sqlIgnore();
+		$sql = <<<SQL
+			INSERT {$ignore} INTO `_entrytag`(id_tag, id_entry) VALUES
+			SQL;
 		foreach ($addLabels as $addLabel) {
 			$id_tag = (int)($addLabel['id_tag'] ?? 0);
 			$id_entry = $addLabel['id_entry'] ?? '';
@@ -344,16 +360,13 @@ SQL;
 	 */
 	public function getTagsForEntry(string $id_entry): array {
 		$sql = <<<'SQL'
-SELECT t.id, t.name, et.id_entry IS NOT NULL as checked
-FROM `_tag` t
-LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id AND et.id_entry=?
-ORDER BY t.name
-SQL;
-
-		$stm = $this->pdo->prepare($sql);
-		$values = [$id_entry];
-
-		if ($stm !== false && $stm->execute($values) && ($lines = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
+			SELECT t.id, t.name, et.id_entry IS NOT NULL as checked
+			FROM `_tag` t
+			LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id AND et.id_entry=:id_entry
+			ORDER BY t.name
+			SQL;
+		$lines = $this->fetchAssoc($sql, [':id_entry' => $id_entry]);
+		if ($lines !== null) {
 			$result = [];
 			foreach ($lines as $line) {
 				/** @var array{id:int,name:string,checked:int} $line */
@@ -365,7 +378,7 @@ SQL;
 			}
 			return $result;
 		}
-		$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
+		$info = $this->pdo->errorInfo();
 		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 		return [];
 	}
@@ -376,10 +389,10 @@ SQL;
 	 */
 	public function getTagsForEntries(array $entries): array|null {
 		$sql = <<<'SQL'
-SELECT et.id_entry, et.id_tag, t.name
-FROM `_tag` t
-INNER JOIN `_entrytag` et ON et.id_tag = t.id
-SQL;
+		SELECT et.id_entry, et.id_tag, t.name
+		FROM `_tag` t
+		INNER JOIN `_entrytag` et ON et.id_tag = t.id
+		SQL;
 
 		$values = [];
 		if (count($entries) > 0) {
@@ -395,7 +408,10 @@ SQL;
 				}
 				return $values;
 			}
-			$sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1) . '?)';
+			$idPlaceholders = str_repeat('?,', count($entries) - 1) . '?';
+			$sql .= "\n" . <<<SQL
+				AND et.id_entry IN ({$idPlaceholders})
+				SQL;
 			foreach ($entries as $entry) {
 				$values[] = is_object($entry) ? $entry->id() : $entry;
 			}

+ 2 - 2
app/Models/TagDAOPGSQL.php

@@ -11,8 +11,8 @@ class FreshRSS_TagDAOPGSQL extends FreshRSS_TagDAO {
 	#[\Override]
 	public function sqlResetSequence(): bool {
 		$sql = <<<'SQL'
-SELECT setval('`_tag_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_tag`
-SQL;
+			SELECT setval('`_tag_id_seq`', COALESCE(MAX(id), 0) + 1, false) FROM `_tag`
+			SQL;
 		return $this->pdo->exec($sql) !== false;
 	}
 }

+ 49 - 31
lib/Minz/ModelPdo.php

@@ -180,32 +180,42 @@ class Minz_ModelPdo {
 	}
 
 	/**
+	 * If $values is not empty, will use a prepared statement, otherwise will execute the query directly.
 	 * @param array<string,int|string|null> $values
 	 * @phpstan-return ($mode is PDO::FETCH_ASSOC ? list<array<string,int|string|null>>|null : list<int|string|null>|null)
 	 * @return list<array<string,int|string|null>>|list<int|string|null>|null
 	 */
 	private function fetchAny(string $sql, array $values, int $mode, int $column = 0): ?array {
-		$stm = $this->pdo->prepare($sql);
-		$ok = $stm !== false;
-		if ($ok && !empty($values)) {
-			foreach ($values as $name => $value) {
-				if (is_int($value)) {
-					$type = PDO::PARAM_INT;
-				} elseif (is_string($value)) {
-					$type = PDO::PARAM_STR;
-				} elseif (is_null($value)) {
-					$type = PDO::PARAM_NULL;
-				} else {
-					$ok = false;
-					break;
-				}
-				if (!$stm->bindValue($name, $value, $type)) {
-					$ok = false;
-					break;
+		$ok = true;
+		$stm = false;
+		if (empty($values)) {
+			$stm = $this->pdo->query($sql);
+		} else {
+			$stm = $this->pdo->prepare($sql);
+			$ok = $stm !== false;
+			if ($ok) {
+				foreach ($values as $name => $value) {
+					if (is_int($value)) {
+						$type = PDO::PARAM_INT;
+					} elseif (is_string($value)) {
+						$type = PDO::PARAM_STR;
+					} elseif (is_null($value)) {
+						$type = PDO::PARAM_NULL;
+					} else {
+						$ok = false;
+						break;
+					}
+					if (!$stm->bindValue($name, $value, $type)) {
+						$ok = false;
+						break;
+					}
 				}
 			}
+			if ($ok && $stm !== false) {
+				$stm = $stm->execute() ? $stm : false;
+			}
 		}
-		if ($ok && $stm !== false && $stm->execute()) {
+		if ($ok && $stm !== false) {
 			switch ($mode) {
 				case PDO::FETCH_COLUMN:
 					$res = $stm->fetchAll(PDO::FETCH_COLUMN, $column);
@@ -236,7 +246,7 @@ class Minz_ModelPdo {
 
 	/**
 	 * @param array<string,int|string|null> $values
-	 * @return list<array<string,int|string|null>>|null
+	 * @return list<array<string,bool|int|string|null>>|null
 	 */
 	public function fetchAssoc(string $sql, array $values = []): ?array {
 		return $this->fetchAny($sql, $values, PDO::FETCH_ASSOC);
@@ -250,18 +260,26 @@ class Minz_ModelPdo {
 		return $this->fetchAny($sql, $values, PDO::FETCH_COLUMN, $column);
 	}
 
-	/** For retrieving a single value without prepared statement such as `SELECT version()` */
+	/**
+	 * For retrieving a single integer value with or without prepared statement such as `SELECT COUNT(*) FROM ...`
+	 * @param array<string,int|string|null> $values Array of values to bind. If not empty, will use a prepared statement
+	 */
+	public function fetchInt(string $sql, array $values = []): ?int {
+		$column = $this->fetchAny($sql, $values, PDO::FETCH_COLUMN, column: 0);
+		return is_numeric($column[0] ?? null) ? (int)$column[0] : null;
+	}
+
+	/**
+	 * For retrieving a single value with or without prepared statement such as `SELECT version()`
+	 * @param array<string,int|string|null> $values Array of values to bind. If not empty, will use a prepared statement
+	 */
+	public function fetchString(string $sql, array $values = []): ?string {
+		$column = $this->fetchAny($sql, $values, PDO::FETCH_COLUMN, column: 0);
+		return is_scalar($column[0] ?? null) ? (string)$column[0] : null;
+	}
+
+	#[Deprecated('Use `fetchString()` instead.')]
 	public function fetchValue(string $sql): ?string {
-		$stm = $this->pdo->query($sql);
-		if ($stm === false) {
-			Minz_Log::error('SQL error ' . json_encode($this->pdo->errorInfo()) . ' during ' . $sql);
-			return null;
-		}
-		$columns = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		if ($columns === false) {
-			Minz_Log::error('SQL error ' . json_encode($stm->errorInfo()) . ' during ' . $sql);
-			return null;
-		}
-		return is_scalar($columns[0] ?? null) ? (string)$columns[0] : null;
+		return $this->fetchString($sql);
 	}
 }

+ 23 - 11
p/api/fever.php

@@ -89,34 +89,46 @@ final class FeverDAO extends Minz_ModelPdo
 		$values = [];
 		$order = '';
 		$entryDAO = FreshRSS_Factory::createEntryDao();
-
-		$sql = 'SELECT id, guid, title, author, '
-			. ($entryDAO::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, attributes '
-			. 'FROM `_entry` WHERE';
+		$contentField = $entryDAO::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
+		$sql = <<<SQL
+			SELECT id, guid, title, author, {$contentField}, link, date, is_read, is_favorite, id_feed, attributes
+			FROM `_entry` WHERE
+			SQL;
 
 		if (!empty($entry_ids)) {
 			$bindEntryIds = $this->bindParamArray('id', $entry_ids, $values);
-			$sql .= " id IN($bindEntryIds)";
+			$sql .= "\n" . <<<SQL
+				id IN($bindEntryIds)
+				SQL;
 		} elseif ($max_id != '') {
-			$sql .= ' id < :id';
+			$sql .= "\n" . <<<'SQL'
+				id < :id
+				SQL;
 			$values[':id'] = $max_id;
 			$order = ' ORDER BY id DESC';
 		} elseif ($since_id != '') {
-			$sql .= ' id > :id';
+			$sql .= "\n" . <<<'SQL'
+				id > :id
+				SQL;
 			$values[':id'] = $since_id;
 			$order = ' ORDER BY id ASC';
 		} else {
-			$sql .= ' 1=1';
+			$sql .= "\n" . <<<'SQL'
+				1=1
+				SQL;
 		}
 
 		if (!empty($feed_ids)) {
 			$bindFeedIds = $this->bindParamArray('feed', $feed_ids, $values);
-			$sql .= " AND id_feed IN($bindFeedIds)";
+			$sql .= "\n" . <<<SQL
+				AND id_feed IN($bindFeedIds)
+				SQL;
 		}
 
 		$sql .= $order;
-		$sql .= ' LIMIT 50';
+		$sql .= "\n" . <<<'SQL'
+			LIMIT 50
+			SQL;
 
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false && $stm->execute($values)) {