瀏覽代碼

PHPStan Level 7 for more DAO PDO (#5328)

* PHPStan Level 7 for more DAO PDO
With new function to address common type and check problems

* A bit more

* PHPStan Level 7 for FreshRSS_Entry
Alexandre Alapetite 2 年之前
父節點
當前提交
c72914bba2

+ 8 - 4
app/Controllers/categoryController.php

@@ -79,6 +79,8 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
 
 	/**
 	 * This action updates the given category.
+	 * @todo Check whether this function is used at all
+	 * @see FreshRSS_subscription_Controller::categoryAction() (consider merging)
 	 *
 	 * Request parameters are:
 	 *   - id
@@ -97,14 +99,16 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
 				Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
 			}
 
-			if ($catDAO->searchById($id) == null) {
+			$cat = $catDAO->searchById($id);
+			if ($cat === null) {
 				Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect);
 			}
 
-			$cat = new FreshRSS_Category($name);
-			$values = array(
+			$values = [
 				'name' => $cat->name(),
-			);
+				'kind' => $cat->kind(),
+				'attributes' => $cat->attributes(),
+			];
 
 			if ($catDAO->updateCategory($id, $values)) {
 				Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);

+ 21 - 12
app/Controllers/statsController.php

@@ -47,25 +47,34 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/chart.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/chart.min.js')));
 
-		$this->view->repartition = $statsDAO->calculateEntryRepartition();
+		$this->view->repartitions = $statsDAO->calculateEntryRepartition();
 
 		$entryCount = $statsDAO->calculateEntryCount();
-		$this->view->entryCount = $entryCount;
-		$this->view->average = round(array_sum(array_values($entryCount)) / count($entryCount), 2);
+		if (is_array($entryCount) && count($entryCount) > 0) {
+			$this->view->entryCount = $entryCount;
+			$this->view->average = round(array_sum(array_values($entryCount)) / count($entryCount), 2);
+		} else {
+			$this->view->entryCount = [];
+			$this->view->average = -1.0;
+		}
 
-		$feedByCategory_calculated = $statsDAO->calculateFeedByCategory();
 		$feedByCategory = [];
-		for ($i = 0; $i < count($feedByCategory_calculated); $i++) {
-			$feedByCategory['label'][$i] 	= $feedByCategory_calculated[$i]['label'];
-			$feedByCategory['data'][$i] 	= $feedByCategory_calculated[$i]['data'];
+		$feedByCategory_calculated = $statsDAO->calculateFeedByCategory();
+		if (is_array($feedByCategory_calculated)) {
+			for ($i = 0; $i < count($feedByCategory_calculated); $i++) {
+				$feedByCategory['label'][$i] = $feedByCategory_calculated[$i]['label'];
+				$feedByCategory['data'][$i] = $feedByCategory_calculated[$i]['data'];
+			}
 		}
 		$this->view->feedByCategory = $feedByCategory;
 
-		$entryByCategory_calculated = $statsDAO->calculateEntryByCategory();
 		$entryByCategory = [];
-		for ($i = 0; $i < count($entryByCategory_calculated); $i++) {
-			$entryByCategory['label'][$i] 	= $entryByCategory_calculated[$i]['label'];
-			$entryByCategory['data'][$i] 	= $entryByCategory_calculated[$i]['data'];
+		$entryByCategory_calculated = $statsDAO->calculateEntryByCategory();
+		if (is_array($entryByCategory_calculated)) {
+			for ($i = 0; $i < count($entryByCategory_calculated); $i++) {
+				$entryByCategory['label'][$i] = $entryByCategory_calculated[$i]['label'];
+				$entryByCategory['data'][$i] = $entryByCategory_calculated[$i]['data'];
+			}
 		}
 		$this->view->entryByCategory = $entryByCategory;
 
@@ -114,7 +123,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
 		$feed_dao = FreshRSS_Factory::createFeedDao();
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
-		$feeds = $statsDAO->calculateFeedLastDate();
+		$feeds = $statsDAO->calculateFeedLastDate() ?: [];
 		$idleFeeds = array(
 			'last_5_year' => array(),
 			'last_3_year' => array(),

+ 1 - 1
app/Controllers/tagController.php

@@ -132,7 +132,7 @@ class FreshRSS_tag_Controller extends FreshRSS_ActionController {
 		$targetTag = $tagDAO->searchByName($targetName);
 		if ($targetTag === null) {
 			// There is no existing tag with the same target name
-			$tagDAO->updateTag($sourceId, ['name' => $targetName]);
+			$tagDAO->updateTagName($sourceId, $targetName);
 		} else {
 			// There is an existing tag with the same target name
 			$tagDAO->updateEntryTag($sourceId, $targetTag->id());

+ 24 - 32
app/Models/CategoryDAO.php

@@ -7,7 +7,7 @@ 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) {
+		if ($stm !== false) {
 			$stm->bindValue(':id', self::DEFAULTCATEGORYID, PDO::PARAM_INT);
 			$stm->bindValue(':name', 'Uncategorized');
 		}
@@ -115,14 +115,14 @@ SQL;
 			$valuesTmp['name'],
 		);
 
-		if ($stm && $stm->execute($values) && $stm->rowCount() > 0) {
+		if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
 			return $this->pdo->lastInsertId('`_category_id_seq`');
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->addCategory($valuesTmp);
 			}
-			Minz_Log::error('SQL error addCategory: ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -143,7 +143,7 @@ SQL;
 	}
 
 	/**
-	 * @param array<string,mixed> $valuesTmp
+	 * @param array{'name':string,'kind':int,'attributes':array<string,mixed>} $valuesTmp
 	 * @return int|false
 	 */
 	public function updateCategory(int $id, array $valuesTmp) {
@@ -155,7 +155,7 @@ 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'])) {
+		if (empty($valuesTmp['attributes'])) {
 			$valuesTmp['attributes'] = [];
 		}
 		$values = array(
@@ -166,14 +166,14 @@ SQL;
 			$valuesTmp['name'],
 		);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->updateCategory($id, $valuesTmp);
 			}
-			Minz_Log::error('SQL error updateCategory: ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -188,11 +188,11 @@ SQL;
 		];
 		$stm = $this->pdo->prepare($sql);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -205,20 +205,20 @@ SQL;
 		$sql = 'DELETE FROM `_category` WHERE id=:id';
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id', $id, PDO::PARAM_INT);
-		if ($stm && $stm->execute()) {
+		if ($stm !== false && $stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error deleteCategory: ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
 
-	/** @return Traversable<array<string,string|int>> */
+	/** @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`';
 		$stm = $this->pdo->query($sql);
-		if ($stm != false) {
+		if ($stm !== false) {
 			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
 				yield $row;
 			}
@@ -235,7 +235,7 @@ SQL;
 	public function searchById(int $id): ?FreshRSS_Category {
 		$sql = 'SELECT * FROM `_category` WHERE id=:id';
 		$stm = $this->pdo->prepare($sql);
-		if ($stm &&
+		if ($stm !== false &&
 			$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
 			$stm->execute()) {
 			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
@@ -250,19 +250,12 @@ SQL;
 	/** @return FreshRSS_Category|null|false */
 	public function searchByName(string $name) {
 		$sql = 'SELECT * FROM `_category` WHERE name=:name';
-		$stm = $this->pdo->prepare($sql);
-		if ($stm == false) {
+		$res = $this->fetchAssoc($sql, ['name' => $name]);
+		if ($res == null) {
 			return false;
 		}
-		$stm->bindParam(':name', $name);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$cat = self::daoToCategory($res);
-		if (isset($cat[0])) {
-			return $cat[0];
-		} else {
-			return null;
-		}
+		return $cat[0] ?? null;
 	}
 
 	/** @return array<FreshRSS_Category>|false */
@@ -300,20 +293,19 @@ SQL;
 				. 'ORDER BY c.name, f.name';
 			$stm = $this->pdo->prepare($sql);
 			$values = [ ':priority_normal' => FreshRSS_Feed::PRIORITY_NORMAL ];
-			if ($stm && $stm->execute($values)) {
+			if ($stm !== false && $stm->execute($values)) {
 				return self::daoToCategoryPrepopulated($stm->fetchAll(PDO::FETCH_ASSOC));
 			} else {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 				if ($this->autoUpdateDb($info)) {
 					return $this->listCategories($prePopulateFeeds, $details);
 				}
-				Minz_Log::error('SQL error listCategories: ' . json_encode($info));
+				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 				return false;
 			}
 		} else {
-			$sql = 'SELECT * FROM `_category` ORDER BY name';
-			$stm = $this->pdo->query($sql);
-			return self::daoToCategory($stm->fetchAll(PDO::FETCH_ASSOC));
+			$res = $this->fetchAssoc('SELECT * FROM `_category` ORDER BY name');
+			return $res == null ? false : self::daoToCategory($res);
 		}
 	}
 
@@ -322,7 +314,7 @@ SQL;
 		$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
 			. ($limit < 1 ? '' : ' LIMIT ' . $limit);
 		$stm = $this->pdo->prepare($sql);
-		if ($stm &&
+		if ($stm !== false &&
 			$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
 			$stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) &&
 			$stm->execute()) {
@@ -376,11 +368,11 @@ SQL;
 				$cat->name(),
 			);
 
-			if ($stm && $stm->execute($values)) {
+			if ($stm !== false && $stm->execute($values)) {
 				return $this->pdo->lastInsertId('`_category_id_seq`');
 			} else {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-				Minz_Log::error('SQL error check default category: ' . json_encode($info));
+				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 				return false;
 			}
 		}

+ 34 - 25
app/Models/DatabaseDAO.php

@@ -36,8 +36,11 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		try {
 			$sql = 'SELECT 1';
 			$stm = $this->pdo->query($sql);
+			if ($stm === false) {
+				return 'Error during SQL connection test!';
+			}
 			$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-			return $res == false ? 'Error during SQL connection test!' : '';
+			return $res == false ? 'Error during SQL connection fetch test!' : '';
 		} catch (Exception $e) {
 			syslog(LOG_DEBUG, __method__ . ' warning: ' . $e->getMessage());
 			return $e->getMessage();
@@ -45,8 +48,10 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 	}
 
 	public function tablesAreCorrect(): bool {
-		$stm = $this->pdo->query('SHOW TABLES');
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$res = $this->fetchAssoc('SHOW TABLES');
+		if ($res == null) {
+			return false;
+		}
 
 		$tables = array(
 			$this->pdo->prefix() . 'category' => false,
@@ -60,21 +65,23 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 			$tables[array_pop($value)] = true;
 		}
 
-		return count(array_keys($tables, true, true)) == count($tables);
+		return count(array_keys($tables, true, true)) === count($tables);
 	}
 
-	/** @return array<array<string,string|bool>> */
+	/** @return array<array<string,string|int|bool|null>> */
 	public function getSchema(string $table): array {
-		$sql = 'DESC `_' . $table . '`';
-		$stm = $this->pdo->query($sql);
-		return $this->listDaoToSchema($stm->fetchAll(PDO::FETCH_ASSOC));
+		$res = $this->fetchAssoc('DESC `_' . $table . '`');
+		return $res == null ? [] : $this->listDaoToSchema($res);
 	}
 
 	/** @param array<string> $schema */
 	public function checkTable(string $table, array $schema): bool {
 		$columns = $this->getSchema($table);
+		if (count($columns) === 0 || count($schema) === 0) {
+			return false;
+		}
 
-		$ok = (count($columns) == count($schema));
+		$ok = count($columns) === count($schema);
 		foreach ($columns as $c) {
 			$ok &= in_array($c['name'], $schema);
 		}
@@ -123,21 +130,21 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 	}
 
 	/**
-	 * @param array<string,string> $dao
-	 * @return array<string,string|bool>
+	 * @param array<string,string|int|bool|null> $dao
+	 * @return array<string,string|int|bool|null>
 	 */
 	public function daoToSchema(array $dao): array {
-		return array(
-			'name' => $dao['Field'],
-			'type' => strtolower($dao['Type']),
+		return [
+			'name' => (string)($dao['Field']),
+			'type' => strtolower((string)($dao['Type'])),
 			'notnull' => (bool)$dao['Null'],
 			'default' => $dao['Default'],
-		);
+		];
 	}
 
 	/**
-	 * @param array<array<string,string>> $listDAO
-	 * @return array<array<string,string|bool>>
+	 * @param array<array<string,string|int|bool|null>> $listDAO
+	 * @return array<array<string,string|int|bool|null>>
 	 */
 	public function listDaoToSchema(array $listDAO): array {
 		$list = array();
@@ -151,16 +158,18 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	public function size(bool $all = false): int {
 		$db = FreshRSS_Context::$system_conf->db;
-		$sql = 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema=?';	//MySQL
-		$values = array($db['base']);
+		//MySQL:
+		$sql = <<<'SQL'
+SELECT SUM(data_length + index_length)
+FROM information_schema.TABLES WHERE table_schema=:table_schema
+SQL;
+		$values = [':table_schema' => $db['base']];
 		if (!$all) {
-			$sql .= ' AND table_name LIKE ?';
-			$values[] = $this->pdo->prefix() . '%';
+			$sql .= ' AND table_name LIKE :table_name';
+			$values[':table_name'] = $this->pdo->prefix() . '%';
 		}
-		$stm = $this->pdo->prepare($sql);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return intval($res[0]);
+		$res = $this->fetchColumn($sql, 0, $values);
+		return isset($res[0]) ? (int)($res[0]) : -1;
 	}
 
 	public function optimize(): bool {

+ 24 - 30
app/Models/DatabaseDAOPGSQL.php

@@ -11,56 +11,54 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
 
 	public function tablesAreCorrect(): bool {
 		$db = FreshRSS_Context::$system_conf->db;
-		$dbowner = $db['user'];
-		$sql = 'SELECT * FROM pg_catalog.pg_tables where tableowner=?';
-		$stm = $this->pdo->prepare($sql);
-		$values = array($dbowner);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$sql = 'SELECT * FROM pg_catalog.pg_tables where tableowner=:tableowner';
+		$res = $this->fetchAssoc($sql, [':tableowner' => $db['user']]);
+		if ($res == null) {
+			return false;
+		}
 
-		$tables = array(
+		$tables = [
 			$this->pdo->prefix() . 'category' => false,
 			$this->pdo->prefix() . 'feed' => false,
 			$this->pdo->prefix() . 'entry' => false,
 			$this->pdo->prefix() . 'entrytmp' => false,
 			$this->pdo->prefix() . 'tag' => false,
 			$this->pdo->prefix() . 'entrytag' => false,
-		);
+		];
 		foreach ($res as $value) {
 			$tables[array_pop($value)] = true;
 		}
 
-		return count(array_keys($tables, true, true)) == count($tables);
+		return count(array_keys($tables, true, true)) === count($tables);
 	}
 
-	/** @return array<array<string,string|bool>> */
+	/** @return array<array<string,string|int|bool|null>> */
 	public function getSchema(string $table): array {
-		$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 = ?';
-		$stm = $this->pdo->prepare($sql);
-		$stm->execute(array($this->pdo->prefix() . $table));
-		return $this->listDaoToSchema($stm->fetchAll(PDO::FETCH_ASSOC));
+		$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;
+		$res = $this->fetchAssoc($sql, [':table_name' => $this->pdo->prefix() . $table]);
+		return $res == null ? [] : $this->listDaoToSchema($res);
 	}
 
 	/**
-	 * @param array<string,string> $dao
-	 * @return array<string,string|bool>
+	 * @param array<string,string|int|bool|null> $dao
+	 * @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
 	 */
 	public function daoToSchema(array $dao): array {
-		return array(
-			'name' => $dao['field'],
-			'type' => strtolower($dao['type']),
+		return [
+			'name' => (string)($dao['field']),
+			'type' => strtolower((string)($dao['type'])),
 			'notnull' => (bool)$dao['null'],
 			'default' => $dao['default'],
-		);
+		];
 	}
 
 	public function size(bool $all = false): int {
 		if ($all) {
 			$db = FreshRSS_Context::$system_conf->db;
-			$sql = 'SELECT pg_database_size(:base)';
-			$stm = $this->pdo->prepare($sql);
-			$stm->bindParam(':base', $db['base']);
-			$stm->execute();
+			$res = $this->fetchColumn('SELECT pg_database_size(:base)', 0, [':base' => $db['base']]);
 		} else {
 			$sql = <<<SQL
 SELECT
@@ -71,13 +69,9 @@ 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;
-			$stm = $this->pdo->query($sql);
-		}
-		if ($stm == false) {
-			return 0;
+			$res = $this->fetchColumn($sql, 0);
 		}
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return intval($res[0]);
+		return intval($res[0] ?? -1);
 	}
 
 

+ 1 - 1
app/Models/DatabaseDAOSQLite.php

@@ -28,7 +28,7 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 		return count(array_keys($tables, true, true)) == count($tables);
 	}
 
-	/** @return array<array<string,string|bool>> */
+	/** @return array<array<string,string|int|bool|null>> */
 	public function getSchema(string $table): array {
 		$sql = 'PRAGMA table_info(' . $table . ')';
 		$stm = $this->pdo->query($sql);

+ 27 - 12
app/Models/Entry.php

@@ -40,9 +40,11 @@ class FreshRSS_Entry extends Minz_Model {
 
 	/**
 	 * @param int|string $pubdate
+	 * @param bool|int|null $is_read
+	 * @param bool|int|null $is_favorite
 	 */
 	public function __construct(int $feedId = 0, string $guid = '', string $title = '', string $authors = '', string $content = '',
-			string $link = '', $pubdate = 0, ?bool $is_read = false, ?bool $is_favorite = false, string $tags = '') {
+			string $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, string $tags = '') {
 		$this->_title($title);
 		$this->_authors($authors);
 		$this->_content($content);
@@ -55,11 +57,18 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->_guid($guid);
 	}
 
-	/** @param array<string,string|int> $dao */
+	/** @param array{'id'?:string,'id_feed'?:int,'guid'?:string,'title'?:string,'author'?:string,'content'?:string,'link'?:string,'date'?:int|string,
+	 *		'is_read'?:bool|int,'is_favorite'?:bool|int,'tags'?:string,'attributes'?:string,'thumbnail'?:string,'timestamp'?:string,'categories'?:string} $dao */
 	public static function fromArray(array $dao): FreshRSS_Entry {
 		if (empty($dao['content'])) {
 			$dao['content'] = '';
 		}
+
+		$dao['attributes'] = empty($dao['attributes']) ? [] : json_decode($dao['attributes'], true);
+		if (!is_array($dao['attributes'])) {
+			$dao['attributes'] = [];
+		}
+
 		if (!empty($dao['thumbnail'])) {
 			$dao['attributes']['thumbnail'] = [
 				'url' => $dao['thumbnail'],
@@ -81,7 +90,7 @@ class FreshRSS_Entry extends Minz_Model {
 			$entry->_id($dao['id']);
 		}
 		if (!empty($dao['timestamp'])) {
-			$entry->_date(strtotime($dao['timestamp']));
+			$entry->_date(strtotime($dao['timestamp']) ?: 0);
 		}
 		if (!empty($dao['categories'])) {
 			$entry->_tags($dao['categories']);
@@ -283,7 +292,7 @@ HTML;
 	}
 
 	/**
-	 * @return array<string,string>|null
+	 * @return array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string,'height'?:int,'width'?:int,'thumbnails'?:array<string>}|null
 	 */
 	public function thumbnail(bool $searchEnclosures = true): ?array {
 		$thumbnail = $this->attributes('thumbnail');
@@ -317,7 +326,11 @@ HTML;
 	public function machineReadableDate(): string {
 		return @date (DATE_ATOM, $this->date);
 	}
-	/** @return int|string */
+
+	/**
+	 * @phpstan-return ($raw is false ? string : ($microsecond is true ? string : int))
+	 * @return int|string
+	 */
 	public function dateAdded(bool $raw = false, bool $microsecond = false) {
 		if ($raw) {
 			if ($microsecond) {
@@ -437,10 +450,10 @@ HTML;
 		if (!is_array($value)) {
 			if (strpos($value, ';') !== false) {
 				$value = htmlspecialchars_decode($value, ENT_QUOTES);
-				$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: '';
+				$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
 				$value = Minz_Helper::htmlspecialchars_utf8($value);
 			} else {
-				$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+				$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
 			}
 		}
 		$this->authors = $value;
@@ -462,15 +475,17 @@ HTML;
 	/** @param int|string $value */
 	public function _dateAdded($value, bool $microsecond = false): void {
 		if ($microsecond) {
-			$this->date_added = $value;
+			$this->date_added = (string)($value);
 		} else {
 			$this->date_added = $value . '000000';
 		}
 	}
-	public function _isRead(?bool $value): void {
+	/** @param bool|int|null $value */
+	public function _isRead($value): void {
 		$this->is_read = $value === null ? null : (bool)$value;
 	}
-	public function _isFavorite(?bool $value): void {
+	/** @param bool|int|null $value */
+	public function _isFavorite($value): void {
 		$this->is_favorite = $value === null ? null : (bool)$value;
 	}
 
@@ -702,7 +717,7 @@ HTML;
 			if ($nodes != false) {
 				foreach ($nodes as $node) {
 					if (!empty($attributes['path_entries_filter'])) {
-						$filterednodes = $xpath->query(new Gt\CssXPath\Translator($attributes['path_entries_filter']), $node);
+						$filterednodes = $xpath->query(new Gt\CssXPath\Translator($attributes['path_entries_filter']), $node) ?: [];
 						foreach ($filterednodes as $filterednode) {
 							$filterednode->parentNode->removeChild($filterednode);
 						}
@@ -790,7 +805,7 @@ HTML;
 	private static function dec2hex($dec): string {
 		return PHP_INT_SIZE < 8 ? // 32-bit ?
 			str_pad(gmp_strval(gmp_init($dec, 10), 16), 16, '0', STR_PAD_LEFT) :
-			str_pad(dechex($dec), 16, '0', STR_PAD_LEFT);
+			str_pad(dechex((int)($dec)), 16, '0', STR_PAD_LEFT);
 	}
 
 	/**

+ 103 - 106
app/Models/EntryDAO.php

@@ -185,7 +185,7 @@ SQL;
 				$this->addEntryPrepared = null;
 				return $this->addEntry($valuesTmp);
 			} elseif ((int)((int)$info[0] / 1000) !== 23) {	//Filter out "SQLSTATE Class code 23: Constraint Violation" because of expected duplicate entries
-				Minz_Log::error('SQL error addEntry: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
 					. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
 			}
 			return false;
@@ -295,7 +295,7 @@ SQL;
 			if ($this->autoUpdateDb($info)) {
 				return $this->updateEntry($valuesTmp);
 			}
-			Minz_Log::error('SQL error updateEntry: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
 				. ' while updating entry with GUID ' . $valuesTmp['guid'] . ' in feed ' . $valuesTmp['id_feed']);
 			return false;
 		}
@@ -333,11 +333,11 @@ SQL;
 		$values = array($is_favorite ? 1 : 0);
 		$values = array_merge($values, $ids);
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markFavorite: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -350,15 +350,15 @@ SQL;
 	 * @todo It can use the query builder refactoring to build that query
 	 */
 	protected function updateCacheUnreads(?int $catId = null, ?int $feedId = null): bool {
-		$sql = 'UPDATE `_feed` f '
-			. 'LEFT OUTER JOIN ('
-			.	'SELECT e.id_feed, '
-			.	'COUNT(*) AS nbUnreads '
-			.	'FROM `_entry` e '
-			.	'WHERE e.is_read=0 '
-			.	'GROUP BY e.id_feed'
-			. ') x ON x.id_feed=f.id '
-			. 'SET f.`cache_nbUnreads`=COALESCE(x.nbUnreads, 0)';
+		$sql = <<<'SQL'
+UPDATE `_feed` f LEFT OUTER JOIN (
+	SELECT e.id_feed, COUNT(*) AS nbUnreads
+	FROM `_entry` e
+	WHERE e.is_read = 0
+	GROUP BY e.id_feed
+) x ON x.id_feed = f.id
+SET f.`cache_nbUnreads` = COALESCE(x.nbUnreads, 0)
+SQL;
 		$hasWhere = false;
 		$values = array();
 		if ($feedId != null) {
@@ -374,11 +374,11 @@ SQL;
 			$values[] = $catId;
 		}
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return true;
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error updateCacheUnreads: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -423,7 +423,7 @@ SQL;
 			$stm = $this->pdo->prepare($sql);
 			if (!($stm && $stm->execute($values))) {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-				Minz_Log::error('SQL error markRead: ' . $info[2]);
+				Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 				return false;
 			}
 			$affected = $stm->rowCount();
@@ -438,11 +438,11 @@ SQL;
 				 . 'WHERE e.id=? AND e.is_read=?';
 			$values = array($is_read ? 1 : 0, $ids, $is_read ? 0 : 1);
 			$stm = $this->pdo->prepare($sql);
-			if ($stm && $stm->execute($values)) {
+			if ($stm !== false && $stm->execute($values)) {
 				return $stm->rowCount();
 			} else {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-				Minz_Log::error('SQL error markRead: ' . $info[2]);
+				Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
 				return false;
 			}
 		}
@@ -490,7 +490,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();
@@ -528,7 +528,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();
@@ -567,7 +567,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadFeed: ' . $info[2] . ' with SQL: ' . $sql . $search);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' with SQL: ' . $sql . $search);
 			$this->pdo->rollBack();
 			return false;
 		}
@@ -581,7 +581,7 @@ SQL;
 			$stm->bindParam(':id', $id_feed, PDO::PARAM_INT);
 			if (!($stm && $stm->execute())) {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-				Minz_Log::error('SQL error markReadFeed cache: ' . $info[2]);
+				Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 				$this->pdo->rollBack();
 				return false;
 			}
@@ -622,7 +622,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();
@@ -684,7 +684,7 @@ SQL;
 
 		$stm = $this->pdo->prepare($sql);
 
-		if ($stm && $stm->execute($params)) {
+		if ($stm !== false && $stm->execute($params)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -718,39 +718,33 @@ SQL;
 	}
 
 	public function searchByGuid(int $id_feed, string $guid): ?FreshRSS_Entry {
-		// un guid est unique pour un flux donné
-		$sql = 'SELECT id, guid, title, author, '
-			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
-			. 'FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
-		$stm = $this->pdo->prepare($sql);
-		$stm->bindParam(':id_feed', $id_feed, PDO::PARAM_INT);
-		$stm->bindParam(':guid', $guid);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
+		$sql = <<<SQL
+SELECT id, guid, title, author, link, date, is_read, is_favorite, id_feed, tags, attributes, {$content}
+FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
+SQL;
+		$res = $this->fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]);
+		/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
+		 *		'is_read':int,'is_favorite':int,'tags':string,'attributes'?:string}> $res */
 		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
 	}
 
 	public function searchById(string $id): ?FreshRSS_Entry {
-		$sql = 'SELECT id, guid, title, author, '
-			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
-			. 'FROM `_entry` WHERE id=:id';
-		$stm = $this->pdo->prepare($sql);
-		$stm->bindParam(':id', $id, PDO::PARAM_INT);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
+		$sql = <<<SQL
+SELECT id, guid, title, author, link, date, is_read, is_favorite, id_feed, tags, attributes, {$content}
+FROM `_entry` WHERE id=:id
+SQL;
+		$res = $this->fetchAssoc($sql, [':id' => $id]);
+		/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
+		 *		'is_read':int,'is_favorite':int,'tags':string,'attributes'?:string}> $res */
 		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
 	}
 
 	public function searchIdByGuid(int $id_feed, string $guid): ?string {
 		$sql = 'SELECT id FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
-		$stm = $this->pdo->prepare($sql);
-		$stm->bindParam(':id_feed', $id_feed, PDO::PARAM_INT);
-		$stm->bindParam(':guid', $guid);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return isset($res[0]) ? $res[0] : null;
+		$res = $this->fetchColumn($sql, 0, [':id_feed' => $id_feed, ':guid' => $guid]);
+		return empty($res[0]) ? null : (string)($res[0]);
 	}
 
 	/** @return array{0:array<int|string>,1:string} */
@@ -1135,14 +1129,14 @@ SQL;
 			. 'ORDER BY e0.id ' . $order;
 
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm;
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
 			}
-			Minz_Log::error('SQL error listWhereRaw: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -1157,6 +1151,8 @@ SQL;
 		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
 		if ($stm) {
 			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
+				/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
+				 *		'is_read':int,'is_favorite':int,'tags':string,'attributes'?:string} $row */
 				yield FreshRSS_Entry::fromArray($row);
 			}
 		}
@@ -1180,17 +1176,24 @@ SQL;
 			}
 			return;
 		}
+		if ($order !== 'DESC' && $order !== 'ASC') {
+			$order = 'DESC';
+		}
 
-		$sql = 'SELECT id, guid, title, author, '
-			. (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-			. ', link, date, is_read, is_favorite, id_feed, tags, attributes '
-			. 'FROM `_entry` '
-			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?) '
-			. 'ORDER BY id ' . $order;
+		$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
+		$repeats = str_repeat('?,', count($ids) - 1) . '?';
+		$sql = <<<SQL
+SELECT id, guid, title, author, link, date, is_read, is_favorite, id_feed, tags, attributes, {$content}
+FROM `_entry`
+WHERE id IN ({$repeats})
+ORDER BY id {$order}
+SQL;
 
 		$stm = $this->pdo->prepare($sql);
 		$stm->execute($ids);
 		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
+			/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
+			 *		'is_read':int,'is_favorite':int,'tags':string,'attributes'?:string} $row */
 			yield FreshRSS_Entry::fromArray($row);
 		}
 	}
@@ -1198,16 +1201,12 @@ SQL;
 	/**
 	 * @phpstan-param 'a'|'A'|'s'|'S'|'c'|'f'|'t'|'T'|'ST' $type
 	 * @param int $id category/feed/tag ID
-	 * @return array<numeric-string>|false
+	 * @return array<numeric-string>
 	 */
 	public function listIdsWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
-		string $order = 'DESC', int $limit = 1, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null) {
+		string $order = 'DESC', int $limit = 1, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null): ?array {
 		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters);
-
-		$stm = $this->pdo->prepare($sql);
-		$stm->execute($values);
-
-		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		return $this->fetchColumn($sql, 0, $values);
 	}
 
 	/**
@@ -1232,7 +1231,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql);
 		$values = array($id_feed);
 		$values = array_merge($values, $guids);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			$rows = $stm->fetchAll(PDO::FETCH_ASSOC);
 			foreach ($rows as $row) {
 				$result[$row['guid']] = $row['hex_hash'];
@@ -1243,7 +1242,7 @@ SQL;
 			if ($this->autoUpdateDb($info)) {
 				return $this->listHashForFeedGuids($id_feed, $guids);
 			}
-			Minz_Log::error('SQL error listHashForFeedGuids: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
 				. ' while querying feed ' . $id_feed);
 			return false;
 		}
@@ -1272,14 +1271,14 @@ SQL;
 		}
 		$values = array($mtime, $id_feed);
 		$values = array_merge($values, $guids);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->updateLastSeen($id_feed, $guids);
 			}
-			Minz_Log::error('SQL error updateLastSeen: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
 				. ' while updating feed ' . $id_feed);
 			return false;
 		}
@@ -1287,32 +1286,33 @@ SQL;
 
 	/** @return array<string,int>|false */
 	public function countUnreadRead() {
-		$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id WHERE f.priority > 0'
-			. ' UNION SELECT COUNT(e.id) AS count FROM `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id WHERE f.priority > 0 AND e.is_read=0';
-		$stm = $this->pdo->query($sql);
-		if ($stm === false) {
+		$sql = <<<'SQL'
+SELECT COUNT(e.id) AS count FROM `_entry` e
+	INNER JOIN `_feed` f ON e.id_feed=f.id
+	WHERE f.priority > 0
+UNION
+SELECT COUNT(e.id) AS count FROM `_entry` e
+	INNER JOIN `_feed` f ON e.id_feed=f.id
+	WHERE f.priority > 0 AND e.is_read=0
+SQL;
+		$res = $this->fetchColumn($sql, 0);
+		if ($res == null) {
 			return false;
 		}
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
 		rsort($res);
-		$all = empty($res[0]) ? 0 : (int)$res[0];
-		$unread = empty($res[1]) ? 0 : (int)$res[1];
-		return array('all' => $all, 'unread' => $unread, 'read' => $all - $unread);
+		$all = (int)($res[0] ?? 0);
+		$unread = (int)($res[1] ?? 0);
+		return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
 	}
 
-	/** @return int|false */
-	public function count(?int $minPriority = null) {
+	public function count(?int $minPriority = null): int {
 		$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
 		if ($minPriority !== null) {
 			$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
-			$sql .= ' WHERE f.priority > ' . $minPriority;
-		}
-		$stm = $this->pdo->query($sql);
-		if ($stm == false) {
-			return false;
+			$sql .= ' WHERE f.priority > :priority';
 		}
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return isset($res[0]) ? intval($res[0]) : 0;
+		$res = $this->fetchColumn($sql, 0, [':priority' => $minPriority]);
+		return isset($res[0]) ? (int)($res[0]) : -1;
 	}
 
 	public function countNotRead(?int $minPriority = null): int {
@@ -1322,11 +1322,10 @@ SQL;
 		}
 		$sql .= ' WHERE e.is_read=0';
 		if ($minPriority !== null) {
-			$sql .= ' AND f.priority > ' . $minPriority;
+			$sql .= ' AND f.priority > :priority';
 		}
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return isset($res[0]) ? intval($res[0]) : 0;
+		$res = $this->fetchColumn($sql, 0, [':priority' => $minPriority]);
+		return isset($res[0]) ? (int)($res[0]) : -1;
 	}
 
 	/** @return array<string,int>|false */
@@ -1334,33 +1333,31 @@ SQL;
 		$sql = <<<'SQL'
 SELECT c FROM (
 	SELECT COUNT(e1.id) AS c, 1 AS o
-		 FROM `_entry` AS e1
-		 JOIN `_feed` AS f1 ON e1.id_feed = f1.id
+		FROM `_entry` AS e1
+		JOIN `_feed` AS f1 ON e1.id_feed = f1.id
 		WHERE e1.is_favorite = 1
-		  AND f1.priority >= :priority_normal1
+		AND f1.priority >= :priority_normal1
 	UNION
 	SELECT COUNT(e2.id) AS c, 2 AS o
-		 FROM `_entry` AS e2
-		 JOIN `_feed` AS f2 ON e2.id_feed = f2.id
+		FROM `_entry` AS e2
+		JOIN `_feed` AS f2 ON e2.id_feed = f2.id
 		WHERE e2.is_favorite = 1
-		  AND e2.is_read = 0
-		  AND f2.priority >= :priority_normal2
+		AND e2.is_read = 0 AND f2.priority >= :priority_normal2
 	) u
 ORDER BY o
 SQL;
-		$stm = $this->pdo->prepare($sql);
-		if (!$stm) {
-			Minz_Log::error('SQL error in ' . __method__ . ' ' . json_encode($this->pdo->errorInfo()));
+		//Binding a value more than once is not standard and does not work with native prepared statements (e.g. MySQL) https://bugs.php.net/bug.php?id=40417
+		$res = $this->fetchColumn($sql, 0, [
+			':priority_normal1' => FreshRSS_Feed::PRIORITY_NORMAL,
+			':priority_normal2' => FreshRSS_Feed::PRIORITY_NORMAL,
+		]);
+		if ($res == null) {
 			return false;
 		}
-		//Binding a value more than once is not standard and does not work with native prepared statements (e.g. MySQL) https://bugs.php.net/bug.php?id=40417
-		$stm->bindValue(':priority_normal1', FreshRSS_Feed::PRIORITY_NORMAL, PDO::PARAM_INT);
-		$stm->bindValue(':priority_normal2', FreshRSS_Feed::PRIORITY_NORMAL, PDO::PARAM_INT);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+
 		rsort($res);
-		$all = empty($res[0]) ? 0 : intval($res[0]);
-		$unread = empty($res[1]) ? 0 : intval($res[1]);
-		return array('all' => $all, 'unread' => $unread, 'read' => $all - $unread);
+		$all = (int)($res[0] ?? 0);
+		$unread = (int)($res[1] ?? 0);
+		return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
 	}
 }

+ 16 - 14
app/Models/EntryDAOSQLite.php

@@ -49,7 +49,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	}
 
 	public function commitNewEntries(): bool {
-		$sql = '
+		$sql = <<<'SQL'
 DROP TABLE IF EXISTS `tmp`;
 CREATE TEMP TABLE `tmp` AS
 	SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
@@ -63,14 +63,14 @@ INSERT OR IGNORE INTO `_entry`
 	ORDER BY date, id;
 DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);
 DROP TABLE IF EXISTS `tmp`;
-';
+SQL;
 		$hadTransaction = $this->pdo->inTransaction();
 		if (!$hadTransaction) {
 			$this->pdo->beginTransaction();
 		}
 		$result = $this->pdo->exec($sql) !== false;
 		if (!$result) {
-			Minz_Log::error('SQL error commitNewEntries: ' . json_encode($this->pdo->errorInfo()));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
 		}
 		if (!$hadTransaction) {
 			$this->pdo->commit();
@@ -79,10 +79,12 @@ DROP TABLE IF EXISTS `tmp`;
 	}
 
 	protected function updateCacheUnreads(?int $catId = null, ?int $feedId = null): bool {
-		$sql = 'UPDATE `_feed` '
-		 . 'SET `cache_nbUnreads`=('
-		 .	'SELECT COUNT(*) AS nbUnreads FROM `_entry` e '
-		 .	'WHERE e.id_feed=`_feed`.id AND e.is_read=0)';
+		$sql = <<<'SQL'
+UPDATE `_feed`
+SET `cache_nbUnreads`=(
+	SELECT COUNT(*) AS nbUnreads FROM `_entry` e
+	WHERE e.id_feed=`_feed`.id AND e.is_read=0)
+SQL;
 		$hasWhere = false;
 		$values = array();
 		if ($feedId != null) {
@@ -98,11 +100,11 @@ DROP TABLE IF EXISTS `tmp`;
 			$values[] = $catId;
 		}
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return true;
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error updateCacheUnreads: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -137,7 +139,7 @@ DROP TABLE IF EXISTS `tmp`;
 			$stm = $this->pdo->prepare($sql);
 			if (!($stm && $stm->execute($values))) {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-				Minz_Log::error('SQL error markRead 1: ' . $info[2]);
+				Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 				$this->pdo->rollBack();
 				return false;
 			}
@@ -149,7 +151,7 @@ DROP TABLE IF EXISTS `tmp`;
 				$stm = $this->pdo->prepare($sql);
 				if (!($stm && $stm->execute($values))) {
 					$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-					Minz_Log::error('SQL error markRead 2: ' . $info[2]);
+					Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
 					$this->pdo->rollBack();
 					return false;
 				}
@@ -201,7 +203,7 @@ DROP TABLE IF EXISTS `tmp`;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();
@@ -240,7 +242,7 @@ DROP TABLE IF EXISTS `tmp`;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();
@@ -279,7 +281,7 @@ DROP TABLE IF EXISTS `tmp`;
 		$stm = $this->pdo->prepare($sql . $search);
 		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 		$affected = $stm->rowCount();

+ 29 - 29
app/Models/FeedDAO.php

@@ -68,14 +68,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
 		);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return (int)($this->pdo->lastInsertId('`_feed_id_seq`'));
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->addFeed($valuesTmp);
 			}
-			Minz_Log::error('SQL error addFeed: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -171,14 +171,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 		$values[] = $id;
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
 				return $this->updateFeed($id, $valuesTmp);
 			}
-			Minz_Log::error('SQL error updateFeed: ' . $info[2] . ' for feed ' . $id);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' for feed ' . $id);
 			return false;
 		}
 	}
@@ -208,7 +208,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		);
 		$stm = $this->pdo->prepare($sql);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
@@ -239,11 +239,11 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 			$idOldCat
 		);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error changeCategory: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -255,11 +255,11 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 
 		$values = array($id);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error deleteFeed: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -277,11 +277,11 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 
 		$values = array($id);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error deleteFeedByCategory: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -301,10 +301,10 @@ SQL;
 
 	public function searchById(int $id): ?FreshRSS_Feed {
 		$sql = 'SELECT * FROM `_feed` WHERE id=:id';
-		$stm = $this->pdo->prepare($sql);
-		$stm->bindParam(':id', $id, PDO::PARAM_INT);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$res = $this->fetchAssoc($sql, [':id' => $id]);
+		if ($res == null) {
+			return null;
+		}
 		$feed = self::daoToFeed($res);
 		return $feed[$id] ?? null;
 	}
@@ -375,7 +375,7 @@ SQL;
 			if ($this->autoUpdateDb($info)) {
 				return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
 			}
-			Minz_Log::error('SQL error listFeedsOrderUpdate: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return array();
 		}
 	}
@@ -388,7 +388,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id_feed', $id, PDO::PARAM_INT);
 
-		if ($stm && $stm->execute()) {
+		if ($stm !== false && $stm->execute()) {
 			return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
 		}
 		return false;
@@ -446,15 +446,15 @@ SQL;
 			. '`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `_entry` e2 WHERE e2.id_feed=`_feed`.id AND e2.is_read=0)'
 			. ($id != 0 ? ' WHERE id=:id' : '');
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $id != 0) {
+		if ($stm !== false && $id != 0) {
 			$stm->bindParam(':id', $id, PDO::PARAM_INT);
 		}
 
-		if ($stm && $stm->execute()) {
+		if ($stm !== false && $stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error updateCachedValue: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -483,7 +483,7 @@ SQL;
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error keepMaxUnread: ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -514,7 +514,7 @@ SQL;
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error markAsReadUponGone: ' . json_encode($info));
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
@@ -529,7 +529,7 @@ SQL;
 		$this->pdo->beginTransaction();
 		if (!($stm && $stm->execute())) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error truncate: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			$this->pdo->rollBack();
 			return false;
 		}
@@ -541,7 +541,7 @@ SQL;
 		$stm->bindParam(':id', $id, PDO::PARAM_INT);
 		if (!($stm && $stm->execute())) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error truncate: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			$this->pdo->rollBack();
 			return false;
 		}
@@ -556,7 +556,7 @@ SQL;
 		$this->pdo->beginTransaction();
 		if (!($stm && $stm->execute())) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error truncate: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 			$this->pdo->rollBack();
 			return false;
 		}
@@ -565,7 +565,7 @@ SQL;
 		$stm = $this->pdo->prepare($sql);
 		if (!($stm && $stm->execute())) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error truncate: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
 			$this->pdo->rollBack();
 			return false;
 		}
@@ -575,7 +575,7 @@ SQL;
 
 	/**
 	 * @param array<int,array<string,string|int>>|array<string,string|int> $listDAO
-	 * @return array<FreshRSS_Feed>
+	 * @return array<int,FreshRSS_Feed>
 	 */
 	public static function daoToFeed(array $listDAO, ?int $catID = null): array {
 		$list = array();
@@ -626,13 +626,13 @@ SQL;
 		$stm = $this->pdo->prepare($sql);
 		if (!($stm && $stm->execute(array(':new_value' => FreshRSS_Feed::TTL_DEFAULT, ':old_value' => -2)))) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL warning updateTTL 1: ' . $info[2] . ' ' . $sql);
+			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 
 			$sql2 = 'ALTER TABLE `_feed` ADD COLUMN ttl INT NOT NULL DEFAULT ' . FreshRSS_Feed::TTL_DEFAULT;	//v0.7.3
 			$stm = $this->pdo->query($sql2);
 			if ($stm === false) {
 				$info = $this->pdo->errorInfo();
-				Minz_Log::error('SQL error updateTTL 2: ' . $info[2] . ' ' . $sql2);
+				Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
 			}
 		} else {
 			$stm->execute(array(':new_value' => -3600, ':old_value' => -1));

+ 37 - 35
app/Models/StatsDAO.php

@@ -11,7 +11,7 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 	/**
 	 * Calculates entry repartition for all feeds and for main stream.
 	 *
-	 * @return array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int},'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}}
+	 * @return array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false}
 	 */
 	public function calculateEntryRepartition(): array {
 		return array(
@@ -28,9 +28,9 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 	 *   - unread entries
 	 *   - favorite entries
 	 *
-	 * @return array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}
+	 * @return array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false
 	 */
-	public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false): array {
+	public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false) {
 		$filter = '';
 		if ($only_main) {
 			$filter .= 'AND f.priority = 10';
@@ -47,10 +47,9 @@ FROM `_entry` AS e, `_feed` AS f
 WHERE e.id_feed = f.id
 {$filter}
 SQL;
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-
-		return $res[0];
+		$res = $this->fetchAssoc($sql);
+		/** @var array<array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}>|null $res */
+		return $res[0] ?? false;
 	}
 
 	/**
@@ -72,14 +71,14 @@ WHERE date >= {$oldest} AND date < {$midnight}
 GROUP BY day
 ORDER BY day ASC
 SQL;
-		$stm = $this->pdo->query($sql);
-		/** @var array<array{'day':int,'count':int}> */
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-
+		$res = $this->fetchAssoc($sql);
+		if ($res == false) {
+			return [];
+		}
+		/** @var array<array{'day':int,'count':int}> $res */
 		foreach ($res as $value) {
-			$count[(int)($value['day'])] = (int) $value['count'];
+			$count[(int)($value['day'])] = (int)($value['count']);
 		}
-
 		return $count;
 	}
 
@@ -138,9 +137,10 @@ GROUP BY period
 ORDER BY period ASC
 SQL;
 
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_NAMED);
-
+		$res = $this->fetchAssoc($sql);
+		if ($res == false) {
+			return [];
+		}
 		switch ($period) {
 			case '%H':
 				$periodMax = 24;
@@ -152,12 +152,12 @@ SQL;
 				$periodMax = 12;
 				break;
 			default:
-			$periodMax = 30;
+				$periodMax = 30;
 		}
 
 		$repartition = array_fill(0, $periodMax, 0);
 		foreach ($res as $value) {
-			$repartition[(int) $value['period']] = (int) $value['count'];
+			$repartition[(int)$value['period']] = (int)$value['count'];
 		}
 
 		return $repartition;
@@ -200,12 +200,14 @@ SELECT COUNT(1) AS count
 FROM `_entry` AS e
 {$restrict}
 SQL;
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetch(PDO::FETCH_NAMED);
+		$res = $this->fetchAssoc($sql);
+		if ($res == null || empty($res[0])) {
+			return -1.0;
+		}
 		$date_min = new \DateTime();
-		$date_min->setTimestamp($res['date_min']);
+		$date_min->setTimestamp((int)($res[0]['date_min']));
 		$date_max = new \DateTime();
-		$date_max->setTimestamp($res['date_max']);
+		$date_max->setTimestamp((int)($res[0]['date_max']));
 		$interval = $date_max->diff($date_min, true);
 		$interval_in_days = (float)($interval->format('%a'));
 		if ($interval_in_days <= 0) {
@@ -214,7 +216,7 @@ SQL;
 			$interval_in_days = $period;
 		}
 
-		return intval($res['count']) / ($interval_in_days / $period);
+		return intval($res[0]['count']) / ($interval_in_days / $period);
 	}
 
 	/**
@@ -240,10 +242,9 @@ WHERE c.id = f.category
 GROUP BY label
 ORDER BY data DESC
 SQL;
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-
-		return $res;
+		$res = $this->fetchAssoc($sql);
+		/** @var array<array{'label':string,'data':int}>|null @res */
+		return $res == null ? [] : $res;
 	}
 
 	/**
@@ -260,10 +261,9 @@ AND f.id = e.id_feed
 GROUP BY label
 ORDER BY data DESC
 SQL;
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-
-		return $res;
+		$res = $this->fetchAssoc($sql);
+		/** @var array<array{'label':string,'data':int}>|null $res */
+		return $res == null ? [] : $res;
 	}
 
 	/**
@@ -283,8 +283,9 @@ GROUP BY f.id
 ORDER BY count DESC
 LIMIT 10
 SQL;
-		$stm = $this->pdo->query($sql);
-		return $stm->fetchAll(PDO::FETCH_ASSOC);
+		$res = $this->fetchAssoc($sql);
+		/** @var array<array{'id':int,'name':string,'category':string,'count':int}>|null $res */
+		return $res == null ? [] : $res;
 	}
 
 	/**
@@ -302,8 +303,9 @@ WHERE f.id = e.id_feed
 GROUP BY f.id
 ORDER BY name
 SQL;
-		$stm = $this->pdo->query($sql);
-		return $stm->fetchAll(PDO::FETCH_ASSOC);
+		$res = $this->fetchAssoc($sql);
+		/** @var array<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>|null $res */
+		return $res == null ? [] : $res;
 	}
 
 	/**

+ 3 - 4
app/Models/StatsDAOPGSQL.php

@@ -47,11 +47,10 @@ GROUP BY period
 ORDER BY period ASC
 SQL;
 
-		$stm = $this->pdo->query($sql);
-		if ($stm === false) {
+		$res = $this->fetchAssoc($sql);
+		if ($res == null) {
 			return [];
 		}
-		$res = $stm->fetchAll(PDO::FETCH_NAMED);
 
 		switch ($period) {
 			case 'hour':
@@ -69,7 +68,7 @@ SQL;
 
 		$repartition = array_fill(0, $periodMax, 0);
 		foreach ($res as $value) {
-			$repartition[(int) $value['period']] = (int) $value['count'];
+			$repartition[(int)$value['period']] = (int)$value['count'];
 		}
 
 		return $repartition;

+ 3 - 4
app/Models/StatsDAOSQLite.php

@@ -24,11 +24,10 @@ GROUP BY period
 ORDER BY period ASC
 SQL;
 
-		$stm = $this->pdo->query($sql);
-		if ($stm === false) {
+		$res = $this->fetchAssoc($sql);
+		if ($res == null) {
 			return [];
 		}
-		$res = $stm->fetchAll(PDO::FETCH_NAMED);
 
 		switch ($period) {
 			case '%H':
@@ -46,7 +45,7 @@ SQL;
 
 		$repartition = array_fill(0, $periodMax, 0);
 		foreach ($res as $value) {
-			$repartition[(int) $value['period']] = (int) $value['count'];
+			$repartition[(int)$value['period']] = (int)$value['count'];
 		}
 
 		return $repartition;

+ 138 - 137
app/Models/TagDAO.php

@@ -43,7 +43,7 @@ class FreshRSS_TagDAO extends Minz_ModelPdo {
 	}
 
 	/**
-	 * @param array<string,string|array<string,mixed>> $valuesTmp
+	 * @param array{'id'?:int,'name':string,'attributes'?:array<string,mixed>} $valuesTmp
 	 * @return int|false
 	 */
 	public function addTag(array $valuesTmp) {
@@ -66,16 +66,17 @@ SQL;
 			$valuesTmp['name'],
 		);
 
-		if ($stm && $stm->execute($values) && $stm->rowCount() > 0) {
+		if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
 			return (int)($this->pdo->lastInsertId('`_tag_id_seq`'));
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error addTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
 
-	public function addTagObject(FreshRSS_Tag $tag): int {
+	/** @return int|false */
+	public function addTagObject(FreshRSS_Tag $tag) {
 		$tag0 = $this->searchByName($tag->name());
 		if (!$tag0) {
 			$values = array(
@@ -87,49 +88,53 @@ SQL;
 		return $tag->id();
 	}
 
-	/**
-	 * @param array<string,string|int|array<string,mixed>> $valuesTmp
-	 * @return int|false
-	 */
-	public function updateTag(int $id, array $valuesTmp) {
+	/** @return int|false */
+	public function updateTagName(int $id, string $name) {
 		// No category of the same name
 		$sql = <<<'SQL'
-UPDATE `_tag` SET name=?, attributes=? WHERE id=?
+UPDATE `_tag` SET name=? WHERE id=?
 AND NOT EXISTS (SELECT 1 FROM `_category` WHERE name = ?)
 SQL;
 
+		$name = mb_strcut(trim($name), 0, 63, 'UTF-8');
 		$stm = $this->pdo->prepare($sql);
-
-		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, 63, 'UTF-8');
-		if (!isset($valuesTmp['attributes'])) {
-			$valuesTmp['attributes'] = [];
-		}
-		$values = array(
-			$valuesTmp['name'],
-			is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
-			$id,
-			$valuesTmp['name'],
-		);
-
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':name', $name, PDO::PARAM_STR) &&
+			$stm->execute()) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error updateTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
 
+	/**
+	 * @param array<string,mixed> $attributes
+	 * @return int|false
+	 */
+	public function updateTagAttributes(int $id, array $attributes) {
+		$sql = 'UPDATE `_tag` SET attributes=:attributes WHERE id=:id';
+		$stm = $this->pdo->prepare($sql);
+		if ($stm !== false &&
+			$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
+			$stm->bindValue(':attributes', json_encode($attributes, JSON_UNESCAPED_SLASHES), PDO::PARAM_STR) &&
+			$stm->execute()) {
+			return $stm->rowCount();
+		}
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
+	}
+
 	/**
 	 * @param mixed $value
 	 * @return int|false
 	 */
 	public function updateTagAttribute(FreshRSS_Tag $tag, string $key, $value) {
 		$tag->_attributes($key, $value);
-		return $this->updateTag(
-				$tag->id(),
-				[ 'attributes' => $tag->attributes() ]
-			);
+		return $this->updateTagAttributes($tag->id(), $tag->attributes());
 	}
 
 	/**
@@ -144,19 +149,23 @@ SQL;
 
 		$values = array($id);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->rowCount();
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error deleteTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
 
-	/** @return Traversable<array{'id':int,'name':string,'attributes':string}> */
+	/** @return Traversable<array{'id':int,'name':string,'attributes'?:array<string,mixed>}> */
 	public function selectAll(): Traversable {
 		$sql = 'SELECT id, name, attributes FROM `_tag`';
 		$stm = $this->pdo->query($sql);
+		if ($stm === false) {
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
+			return;
+		}
 		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
 			yield $row;
 		}
@@ -166,6 +175,10 @@ SQL;
 	public function selectEntryTag(): Traversable {
 		$sql = 'SELECT id_tag, id_entry FROM `_entrytag`';
 		$stm = $this->pdo->query($sql);
+		if ($stm === false) {
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
+			return;
+		}
 		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
 			yield $row;
 		}
@@ -180,53 +193,44 @@ DELETE FROM `_entrytag` WHERE EXISTS (
 SQL;
 		$stm = $this->pdo->prepare($sql);
 
-		if (!($stm && $stm->execute([$newTagId, $oldTagId]))) {
+		if ($stm === false || !$stm->execute([$newTagId, $oldTagId])) {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error moveTag: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
 			return false;
 		}
 
 		$sql = 'UPDATE `_entrytag` SET id_tag = ? WHERE id_tag = ?';
 		$stm = $this->pdo->prepare($sql);
 
-		if ($stm && $stm->execute([$newTagId, $oldTagId])) {
+		if ($stm !== false && $stm->execute([$newTagId, $oldTagId])) {
 			return $stm->rowCount();
-		} else {
-			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error moveTag: ' . $info[2]);
-			return false;
 		}
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
+		return false;
 	}
 
 	public function searchById(int $id): ?FreshRSS_Tag {
-		$sql = 'SELECT * FROM `_tag` WHERE id=?';
-		$stm = $this->pdo->prepare($sql);
-		$values = array($id);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$tag = self::daoToTag($res);
-		return isset($tag[0]) ? $tag[0] : null;
+		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE id=:id', [':id' => $id]);
+		return $res === null ? null : self::daoToTag($res)[0] ?? null;
 	}
 
 	public function searchByName(string $name): ?FreshRSS_Tag {
-		$sql = 'SELECT * FROM `_tag` WHERE name=?';
-		$stm = $this->pdo->prepare($sql);
-		$values = array($name);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$tag = self::daoToTag($res);
-		return isset($tag[0]) ? $tag[0] : null;
+		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE name=:name', [':name' => $name]);
+		return $res === null ? null : self::daoToTag($res)[0] ?? null;
 	}
 
 	/** @return array<FreshRSS_Tag>|false */
 	public function listTags(bool $precounts = false) {
 		if ($precounts) {
-			$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 = <<<'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';
 		}
@@ -239,27 +243,31 @@ SQL;
 			if ($this->autoUpdateDb($info)) {
 				return $this->listTags($precounts);
 			}
-			Minz_Log::error('SQL error listTags: ' . $info[2]);
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
 		}
 	}
 
 	/** @return array<string,string> */
 	public function listTagsNewestItemUsec(?int $id_tag = null): array {
-		$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 = <<<'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 .= ' GROUP BY t.id';
 		} else {
-			$sql .= 'WHERE t.id=' . intval($id_tag);
+			$sql .= ' WHERE t.id=' . intval($id_tag);
+		}
+		$res = $this->fetchAssoc($sql);
+		if ($res == null) {
+			return [];
 		}
-		$stm = $this->pdo->query($sql);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$newestItemUsec = [];
 		foreach ($res as $line) {
-			$newestItemUsec['t_' . $line['id_tag']] = $line['newest_item_us'];
+			$newestItemUsec['t_' . $line['id_tag']] = (string)($line['newest_item_us']);
 		}
 		return $newestItemUsec;
 	}
@@ -271,56 +279,47 @@ SQL;
 		if ($stm !== false) {
 			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 			return (int)$res[0]['count'];
-		} else {
-			$info = $this->pdo->errorInfo();
-			if ($this->autoUpdateDb($info)) {
-				return $this->count();
-			}
-			Minz_Log::error('SQL error TagDAO::count: ' . $info[2]);
-			return false;
 		}
+		$info = $this->pdo->errorInfo();
+		if ($this->autoUpdateDb($info)) {
+			return $this->count();
+		}
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
 	}
 
 	/**
 	 * @return int|false
 	 */
 	public function countEntries(int $id) {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=?';
-		$values = array($id);
-		if (($stm = $this->pdo->prepare($sql)) !== false &&
-			$stm->execute($values) &&
-			($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
-			return (int)$res[0]['count'];
-		} else {
-			$info = is_object($stm) ? $stm->errorInfo() : $this->pdo->errorInfo();
-			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		$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 false;
 		}
+		return (int)$res[0]['count'];
 	}
 
 	/**
 	 * @return int|false
 	 */
 	public function countNotRead(?int $id = null) {
-		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` et '
-			 . 'INNER JOIN `_entry` e ON et.id_entry=e.id '
-			 . 'WHERE e.is_read=0';
-		$values = null;
-
+		$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;
+		$values = [];
 		if (null !== $id) {
-			$sql .= ' AND et.id_tag=?';
-			$values = [$id];
+			$sql .= ' AND et.id_tag=:id_tag';
+			$values[':id_tag'] = $id;
 		}
 
-		if (($stm = $this->pdo->prepare($sql)) !== false &&
-			$stm->execute($values) &&
-			($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
-			return (int)$res[0]['count'];
-		} else {
-			$info = is_object($stm) ? $stm->errorInfo() : $this->pdo->errorInfo();
-			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		$res = $this->fetchAssoc($sql, $values);
+		if ($res == null || !isset($res[0]['count'])) {
 			return false;
 		}
+		return (int)$res[0]['count'];
 	}
 
 	public function tagEntry(int $id_tag, string $id_entry, bool $checked = true): bool {
@@ -332,42 +331,42 @@ SQL;
 		$stm = $this->pdo->prepare($sql);
 		$values = array($id_tag, $id_entry);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return true;
-		} else {
-			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			Minz_Log::error('SQL error tagEntry: ' . $info[2]);
-			return false;
 		}
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
 	}
 
 	/**
 	 * @return array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}>|false
 	 */
 	public function getTagsForEntry(string $id_entry) {
-		$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 = <<<'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 = array($id_entry);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			$lines = $stm->fetchAll(PDO::FETCH_ASSOC);
 			for ($i = count($lines) - 1; $i >= 0; $i--) {
 				$lines[$i]['id'] = intval($lines[$i]['id']);
 				$lines[$i]['checked'] = !empty($lines[$i]['checked']);
 			}
 			return $lines;
-		} else {
-			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			if ($this->autoUpdateDb($info)) {
-				return $this->getTagsForEntry($id_entry);
-			}
-			Minz_Log::error('SQL error getTagsForEntry: ' . $info[2]);
-			return false;
 		}
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		if ($this->autoUpdateDb($info)) {
+			return $this->getTagsForEntry($id_entry);
+		}
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
 	}
 
 	/**
@@ -375,9 +374,11 @@ SQL;
 	 * @return array<array{'id_entry':string,'id_tag':int,'name':string}>|false
 	 */
 	public function getTagsForEntries(array $entries) {
-		$sql = 'SELECT et.id_entry, et.id_tag, t.name '
-			 . 'FROM `_tag` t '
-			 . 'INNER JOIN `_entrytag` et ON et.id_tag = t.id';
+		$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;
 
 		$values = array();
 		if (is_array($entries) && count($entries) > 0) {
@@ -392,9 +393,12 @@ SQL;
 			$sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1). '?)';
 			if (is_array($entries[0])) {
 				foreach ($entries as $entry) {
-					$values[] = $entry['id'];
+					if (!empty($entry['id'])) {
+						$values[] = $entry['id'];
+					}
 				}
 			} elseif (is_object($entries[0])) {
+				/** @var array<FreshRSS_Entry> $entries */
 				foreach ($entries as $entry) {
 					$values[] = $entry->id();
 				}
@@ -406,16 +410,15 @@ SQL;
 		}
 		$stm = $this->pdo->prepare($sql);
 
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			return $stm->fetchAll(PDO::FETCH_ASSOC);
-		} else {
-			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
-			if ($this->autoUpdateDb($info)) {
-				return $this->getTagsForEntries($entries);
-			}
-			Minz_Log::error('SQL error getTagsForEntries: ' . $info[2]);
-			return false;
 		}
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		if ($this->autoUpdateDb($info)) {
+			return $this->getTagsForEntries($entries);
+		}
+		Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+		return false;
 	}
 
 	/**
@@ -425,7 +428,7 @@ SQL;
 	 */
 	public function getEntryIdsTagNames(array $entries): array {
 		$result = array();
-		foreach ($this->getTagsForEntries($entries) as $line) {
+		foreach ($this->getTagsForEntries($entries) ?: [] as $line) {
 			$entryId = 'e_' . $line['id_entry'];
 			$tagName = $line['name'];
 			if (empty($result[$entryId])) {
@@ -437,18 +440,16 @@ SQL;
 	}
 
 	/**
-	 * @param array<array<string,string|int>>|array<string,string|int> $listDAO
+	 * @param iterable<array<string,int|string|null>> $listDAO
 	 * @return array<FreshRSS_Tag>
 	 */
-	private static function daoToTag(array $listDAO): array {
-		$list = array();
-		if (!is_array($listDAO)) {
-			$listDAO = array($listDAO);
-		}
+	private static function daoToTag(iterable $listDAO): array {
+		$list = [];
 		foreach ($listDAO as $dao) {
-			$tag = new FreshRSS_Tag(
-				$dao['name']
-			);
+			if (empty($dao['id']) || !is_int($dao['id']) || empty($dao['name']) || !is_string($dao['name'])) {
+				continue;
+			}
+			$tag = new FreshRSS_Tag($dao['name']);
 			$tag->_id($dao['id']);
 			if (!empty($dao['attributes'])) {
 				$tag->_attributes('', $dao['attributes']);

+ 4 - 2
app/Models/View.php

@@ -201,15 +201,17 @@ class FreshRSS_View extends Minz_View {
 	public $last30DaysLabels;
 	/** @var array<string,string> */
 	public $months;
-	/** @var array<string,array<string,int>>|array<string,int> */
+	/** @var array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false */
 	public $repartition;
+	/** @var array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false} */
+	public $repartitions;
 	/** @var array<int,int> */
 	public $repartitionDayOfWeek;
 	/** @var array<string,int>|array<int,int> */
 	public $repartitionHour;
 	/** @var array<int,int> */
 	public $repartitionMonth;
-	/** @var array<array<string,int|string>> */
+	/** @var array<array{'id':int,'name':string,'category':string,'count':int}> */
 	public $topFeed;
 
 }

+ 2 - 2
app/Services/ExportService.php

@@ -72,7 +72,7 @@ class FreshRSS_Export_Service {
 
 		$view->list_title = _t('sub.import_export.starred_list');
 		$view->type = 'starred';
-		$entriesId = $this->entry_dao->listIdsWhere($type, 0, FreshRSS_Entry::STATE_ALL, 'ASC', -1) ?: [];
+		$entriesId = $this->entry_dao->listIdsWhere($type, 0, FreshRSS_Entry::STATE_ALL, 'ASC', -1) ?? [];
 		$view->entryIdsTagNames = $this->tag_dao->getEntryIdsTagNames($entriesId);
 		// The following is a streamable query, i.e. must be last
 		$view->entries = $this->entry_dao->listWhere(
@@ -108,7 +108,7 @@ class FreshRSS_Export_Service {
 		$view->type = 'feed/' . $feed->id();
 		$entriesId = $this->entry_dao->listIdsWhere(
 			'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC', $max_number_entries
-		) ?: [];
+		) ?? [];
 		$view->entryIdsTagNames = $this->tag_dao->getEntryIdsTagNames($entriesId);
 		// The following is a streamable query, i.e. must be last
 		$view->entries = $this->entry_dao->listWhere(

+ 17 - 13
app/views/stats/index.phtml

@@ -24,29 +24,29 @@
 				<tbody>
 					<tr>
 						<th><?= _t('admin.stats.status_total') ?></th>
-						<td class="numeric"><?= format_number($this->repartition['main_stream']['total']) ?></td>
-						<td class="numeric"><?= format_number($this->repartition['all_feeds']['total']) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['main_stream']['total'] ?? -1) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['all_feeds']['total'] ?? -1) ?></td>
 					</tr>
 					<tr>
 						<th><?= _t('admin.stats.status_read') ?></th>
-						<td class="numeric"><?= format_number($this->repartition['main_stream']['count_reads']) ?></td>
-						<td class="numeric"><?= format_number($this->repartition['all_feeds']['count_reads']) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['main_stream']['count_reads'] ?? -1) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['all_feeds']['count_reads'] ?? -1) ?></td>
 					</tr>
 					<tr>
 						<th><?= _t('admin.stats.status_unread') ?></th>
-						<td class="numeric"><?= format_number($this->repartition['main_stream']['count_unreads']) ?></td>
-						<td class="numeric"><?= format_number($this->repartition['all_feeds']['count_unreads']) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['main_stream']['count_unreads'] ?? -1) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['all_feeds']['count_unreads'] ?? -1) ?></td>
 					</tr>
 					<tr>
 						<th><?= _t('admin.stats.status_favorites') ?></th>
-						<td class="numeric"><?= format_number($this->repartition['main_stream']['count_favorites']) ?></td>
-						<td class="numeric"><?= format_number($this->repartition['all_feeds']['count_favorites']) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['main_stream']['count_favorites'] ?? -1) ?></td>
+						<td class="numeric"><?= format_number($this->repartitions['all_feeds']['count_favorites'] ?? -1) ?></td>
 					</tr>
 				</tbody>
 			</table>
-		</div><!--
+		</div>
 
-		--><div class="stat half">
+		<div class="stat half">
 			<h2><?= _t('admin.stats.top_feed') ?></h2>
 			<table>
 				<thead>
@@ -58,14 +58,18 @@
 					</tr>
 				</thead>
 				<tbody>
-					<?php foreach ($this->topFeed as $feed) { ?>
+					<?php foreach ($this->topFeed as $feed): ?>
 						<tr>
 							<td><a href="<?= _url('stats', 'repartition', 'id', $feed['id']) ?>"><?= $feed['name'] ?></a></td>
 							<td><?= $feed['category'] ?></td>
 							<td class="numeric"><?= format_number($feed['count']) ?></td>
-							<td class="numeric"><?= format_number($feed['count'] / $this->repartition['all_feeds']['total'] * 100, 1) ?></td>
+							<td class="numeric"><?php
+								if (!empty($this->repartitions['all_feeds']['total'])) {
+									echo format_number($feed['count'] / $this->repartitions['all_feeds']['total'] * 100, 1);
+								}
+							?></td>
 						</tr>
-					<?php } ?>
+					<?php endforeach; ?>
 				</tbody>
 			</table>
 		</div>

+ 4 - 4
app/views/stats/repartition.phtml

@@ -46,10 +46,10 @@
 				<th><?= _t('admin.stats.status_favorites') ?></th>
 			</tr>
 			<tr>
-				<td class="numeric"><?= $this->repartition['total'] ?></td>
-				<td class="numeric"><?= $this->repartition['count_reads'] ?></td>
-				<td class="numeric"><?= $this->repartition['count_unreads'] ?></td>
-				<td class="numeric"><?= $this->repartition['count_favorites'] ?></td>
+				<td class="numeric"><?= $this->repartition['total'] ?? -1 ?></td>
+				<td class="numeric"><?= $this->repartition['count_reads'] ?? -1 ?></td>
+				<td class="numeric"><?= $this->repartition['count_unreads'] ?? -1 ?></td>
+				<td class="numeric"><?= $this->repartition['count_favorites'] ?? -1 ?></td>
 			</tr>
 			</table>
 		</div>

+ 3 - 3
cli/user-info.php

@@ -66,7 +66,7 @@ foreach ($users as $username) {
 
 	if ($nbFavorites === false) {
 		$nbFavorites = [
-			'all' => 0,
+			'all' => -1,
 		];
 	}
 
@@ -74,8 +74,8 @@ foreach ($users as $username) {
 
 	if ($nbEntries === false) {
 		$nbEntries = [
-			'read' => 0,
-			'unread' => 0,
+			'read' => -1,
+			'unread' => -1,
 		];
 	}
 

+ 2 - 2
lib/Minz/Configuration.php

@@ -3,8 +3,8 @@
 /**
  * Manage configuration for the application.
  * @property-read string $base_url
- * @property array{'type'?:string,'host'?:string,'user'?:string,'password'?:string,'base'?:string,'prefix'?:string,
- *  'connection_uri_params'?:string,'pdo_options'?:array<string|int,string|int|bool>} $db
+ * @property array{'type':string,'host':string,'user':string,'password':string,'base':string,'prefix':string,
+ *  'connection_uri_params':string,'pdo_options':array<int,int|string|bool>} $db
  * @property-read string $disable_update
  * @property-read string $environment
  * @property array<string,bool> $extensions_enabled

+ 60 - 0
lib/Minz/ModelPdo.php

@@ -179,4 +179,64 @@ class Minz_ModelPdo {
 		self::$sharedPdo = null;
 		self::$sharedCurrentUser = '';
 	}
+
+	/**
+	 * @param array<string,int|string|null> $values
+	 * @phpstan-return ($mode is PDO::FETCH_ASSOC ? array<array<string,int|string|null>>|null : array<int|string|null>|null)
+	 * @return array<array<string,int|string|null>>|array<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;
+				}
+			}
+		}
+		if ($ok && $stm !== false && $stm->execute()) {
+			switch ($mode) {
+				case PDO::FETCH_COLUMN:
+					$res = $stm->fetchAll(PDO::FETCH_COLUMN, $column);
+					break;
+				case PDO::FETCH_ASSOC:
+				default:
+					$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+					break;
+			}
+			if ($res !== false) {
+				return $res;
+			}
+		}
+
+		$callingFunction = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function'] ?? '??';
+		$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . $callingFunction . ' ' . json_encode($info));
+		return null;
+	}
+
+	/**
+	 * @param array<string,int|string|null> $values
+	 * @return array<array<string,int|string|null>>|null
+	 */
+	public function fetchAssoc(string $sql, array $values = []): ?array {
+		return $this->fetchAny($sql, $values, PDO::FETCH_ASSOC);
+	}
+
+	/**
+	 * @param array<string,int|string|null> $values
+	 * @return array<int|string|null>|null
+	 */
+	public function fetchColumn(string $sql, int $column, array $values = []): ?array {
+		return $this->fetchAny($sql, $values, PDO::FETCH_COLUMN, $column);
+	}
 }

+ 1 - 1
lib/Minz/Pdo.php

@@ -6,7 +6,7 @@
  */
 
 abstract class Minz_Pdo extends PDO {
-	/** @param array<int,int|string>|null $options */
+	/** @param array<int,int|string|bool>|null $options */
 	public function __construct(string $dsn, ?string $username = null, ?string $passwd = null, ?array $options = null) {
 		parent::__construct($dsn, $username, $passwd, $options);
 		$this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

+ 1 - 1
lib/Minz/PdoMysql.php

@@ -6,7 +6,7 @@
  */
 
 class Minz_PdoMysql extends Minz_Pdo {
-	/** @param array<int,int|string>|null $options */
+	/** @param array<int,int|string|bool>|null $options */
 	public function __construct(string $dsn, ?string $username = null, ?string $passwd = null, ?array $options = null) {
 		parent::__construct($dsn, $username, $passwd, $options);
 		$this->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);

+ 1 - 1
lib/Minz/PdoPgsql.php

@@ -6,7 +6,7 @@
  */
 
 class Minz_PdoPgsql extends Minz_Pdo {
-	/** @param array<int,int|string>|null $options */
+	/** @param array<int,int|string|bool>|null $options */
 	public function __construct(string $dsn, ?string $username = null, ?string $passwd = null, ?array $options = null) {
 		parent::__construct($dsn, $username, $passwd, $options);
 		$this->exec("SET NAMES 'UTF8';");

+ 1 - 1
lib/Minz/PdoSqlite.php

@@ -6,7 +6,7 @@
  */
 
 class Minz_PdoSqlite extends Minz_Pdo {
-	/** @param array<int,int|string>|null $options */
+	/** @param array<int,int|string|bool>|null $options */
 	public function __construct(string $dsn, ?string $username = null, ?string $passwd = null, ?array $options = null) {
 		parent::__construct($dsn, $username, $passwd, $options);
 		$this->exec('PRAGMA foreign_keys = ON;');

+ 4 - 7
p/api/fever.php

@@ -113,7 +113,7 @@ final class FeverDAO extends Minz_ModelPdo
 		$sql .= ' LIMIT 50';
 
 		$stm = $this->pdo->prepare($sql);
-		if ($stm && $stm->execute($values)) {
+		if ($stm !== false && $stm->execute($values)) {
 			$result = $stm->fetchAll(PDO::FETCH_ASSOC);
 
 			$entries = array();
@@ -374,10 +374,7 @@ final class FeverAPI
 		return $favicons;
 	}
 
-	/**
-	 * @return int|false
-	 */
-	private function getTotalItems() {
+	private function getTotalItems(): int {
 		return $this->entryDAO->count();
 	}
 
@@ -419,12 +416,12 @@ final class FeverAPI
 	}
 
 	private function getUnreadItemIds(): string {
-		$entries = $this->entryDAO->listIdsWhere('a', 0, FreshRSS_Entry::STATE_NOT_READ, 'ASC', 0) ?: [];
+		$entries = $this->entryDAO->listIdsWhere('a', 0, FreshRSS_Entry::STATE_NOT_READ, 'ASC', 0) ?? [];
 		return $this->entriesToIdList($entries);
 	}
 
 	private function getSavedItemIds(): string {
-		$entries = $this->entryDAO->listIdsWhere('a', 0, FreshRSS_Entry::STATE_FAVORITE, 'ASC', 0) ?: [];
+		$entries = $this->entryDAO->listIdsWhere('a', 0, FreshRSS_Entry::STATE_FAVORITE, 'ASC', 0) ?? [];
 		return $this->entriesToIdList($entries);
 	}
 

+ 5 - 3
p/api/greader.php

@@ -746,7 +746,7 @@ final class GReaderAPI {
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$ids = $entryDAO->listIdsWhere($type, $id, $state, $order === 'o' ? 'ASC' : 'DESC', $count, $continuation, $searches);
-		if ($ids === false) {
+		if ($ids == null) {
 			self::internalServerError();
 		}
 
@@ -898,13 +898,15 @@ final class GReaderAPI {
 			$categoryDAO = FreshRSS_Factory::createCategoryDao();
 			$cat = $categoryDAO->searchByName($s);
 			if ($cat != null) {
-				$categoryDAO->updateCategory($cat->id(), array('name' => $dest));
+				$categoryDAO->updateCategory($cat->id(), [
+					'name' => $dest, 'kind' => $cat->kind(), 'attributes' => $cat->attributes()
+				]);
 				exit('OK');
 			} else {
 				$tagDAO = FreshRSS_Factory::createTagDao();
 				$tag = $tagDAO->searchByName($s);
 				if ($tag != null) {
-					$tagDAO->updateTag($tag->id(), array('name' => $dest));
+					$tagDAO->updateTagName($tag->id(), $dest);
 					exit('OK');
 				}
 			}

+ 0 - 8
tests/phpstan-next.txt

@@ -10,23 +10,15 @@
 ./app/Controllers/userController.php
 ./app/Models/CategoryDAO.php
 ./app/Models/Context.php
-./app/Models/DatabaseDAO.php
-./app/Models/DatabaseDAOPGSQL.php
-./app/Models/Entry.php
 ./app/Models/EntryDAO.php
 ./app/Models/Feed.php
 ./app/Models/FeedDAO.php
 ./app/Models/Search.php
 ./app/Models/Share.php
-./app/Models/StatsDAO.php
-./app/Models/TagDAO.php
 ./app/views/helpers/logs_pagination.phtml
-./app/views/stats/index.phtml
-./app/views/stats/repartition.phtml
 ./cli/check.translation.php
 ./cli/manipulate.translation.php
 ./lib/Minz/Error.php
 ./lib/Minz/Mailer.php
 ./lib/Minz/Migrator.php
-./lib/Minz/ModelPdo.php
 ./lib/Minz/Request.php