1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Extensionmanager\Domain\Repository; 17 18use Doctrine\DBAL\Exception as DBALException; 19use TYPO3\CMS\Core\Database\Connection; 20use TYPO3\CMS\Core\Database\ConnectionPool; 21use TYPO3\CMS\Core\Database\Platform\PlatformInformation; 22use TYPO3\CMS\Core\Utility\VersionNumberUtility; 23use TYPO3\CMS\Extensionmanager\Domain\Model\Extension; 24use TYPO3\CMS\Extensionmanager\Parser\ExtensionXmlParser; 25 26/** 27 * Importer object for extension list, which handles the XML parser and writes directly into the database. 28 * 29 * @internal This class is a specific domain repository implementation and is not part of the Public TYPO3 API. 30 */ 31class BulkExtensionRepositoryWriter implements \SplObserver 32{ 33 /** 34 * @var string 35 */ 36 private const TABLE_NAME = 'tx_extensionmanager_domain_model_extension'; 37 38 protected ExtensionXmlParser $parser; 39 40 /** 41 * Keeps number of processed version records. 42 * 43 * @var int 44 */ 45 protected $sumRecords = 0; 46 47 /** 48 * Keeps record values to be inserted into database. 49 * 50 * @var array 51 */ 52 protected $arrRows = []; 53 54 /** 55 * Keeps fieldnames of tx_extensionmanager_domain_model_extension table. 56 * 57 * @var array 58 */ 59 protected static $fieldNames = [ 60 'extension_key', 61 'version', 62 'integer_version', 63 'current_version', 64 'alldownloadcounter', 65 'downloadcounter', 66 'title', 67 'ownerusername', 68 'author_name', 69 'author_email', 70 'authorcompany', 71 'last_updated', 72 'md5hash', 73 'remote', 74 'state', 75 'review_state', 76 'category', 77 'description', 78 'serialized_dependencies', 79 'update_comment', 80 'documentation_link', 81 'distribution_image', 82 'distribution_welcome_image', 83 ]; 84 85 /** 86 * Maximum of rows that can be used in a bulk insert for the current 87 * database platform. 88 * 89 * @var int 90 */ 91 protected $maxRowsPerChunk = 50; 92 93 /** 94 * Keeps the information from which remote the extension list was fetched. 95 * 96 * @var string 97 */ 98 protected $remoteIdentifier; 99 100 /** 101 * @var ExtensionRepository 102 */ 103 protected $extensionRepository; 104 105 /** 106 * @var Extension 107 */ 108 protected $extensionModel; 109 110 /** 111 * @var ConnectionPool 112 */ 113 protected $connectionPool; 114 115 /** 116 * Only import extensions newer than this date (timestamp), 117 * see constructor 118 * 119 * @var int 120 */ 121 protected $minimumDateToImport; 122 123 /** 124 * Method retrieves and initializes extension XML parser instance. 125 * 126 * @param ExtensionRepository $repository 127 * @param Extension $extension 128 * @param ConnectionPool $connectionPool 129 * @param ExtensionXmlParser $parser 130 * @throws DBALException 131 */ 132 public function __construct( 133 ExtensionRepository $repository, 134 Extension $extension, 135 ConnectionPool $connectionPool, 136 ExtensionXmlParser $parser 137 ) { 138 $this->extensionRepository = $repository; 139 $this->extensionModel = $extension; 140 $this->connectionPool = $connectionPool; 141 $this->parser = $parser; 142 $this->parser->attach($this); 143 144 $connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME); 145 $maxBindParameters = PlatformInformation::getMaxBindParameters( 146 $connection->getDatabasePlatform() 147 ); 148 $countOfBindParamsPerRow = count(self::$fieldNames); 149 // flush at least chunks of 50 elements - in case the currently used 150 // database platform does not support that, the threshold is lowered 151 $this->maxRowsPerChunk = (int)min( 152 $this->maxRowsPerChunk, 153 floor($maxBindParameters / $countOfBindParamsPerRow) 154 ); 155 // Only import extensions that are compatible with 7.6 or higher. 156 // TER only allows to publish extensions with compatibility if the TYPO3 version has been released 157 // And 7.6 was released on 10th of November 2015. 158 // This effectively reduces the number of extensions imported into this TYPO3 installation 159 // by more than 70%. As long as the extensions.xml from TER includes these files, we need to "hack" this 160 // within TYPO3 Core. 161 // For TYPO3 v11.0, this date could be set to 2018-10-02 (v9 LTS release). 162 // Also see https://decisions.typo3.org/t/reduce-size-of-extension-manager-db-table/329/ 163 $this->minimumDateToImport = strtotime('2017-04-04T00:00:00+00:00'); 164 } 165 166 /** 167 * Method initializes parsing of extension.xml.gz file. 168 * 169 * @param string $localExtensionListFile absolute path to extension list xml.gz 170 * @param string $remoteIdentifier identifier of the remote when inserting records into DB 171 */ 172 public function import(string $localExtensionListFile, string $remoteIdentifier): void 173 { 174 // Remove all existing entries of this remote from the database 175 $this->connectionPool 176 ->getConnectionForTable(self::TABLE_NAME) 177 ->delete( 178 self::TABLE_NAME, 179 ['remote' => $remoteIdentifier], 180 [\PDO::PARAM_STR] 181 ); 182 $this->remoteIdentifier = $remoteIdentifier; 183 $zlibStream = 'compress.zlib://'; 184 $this->sumRecords = 0; 185 $this->parser->parseXml($zlibStream . $localExtensionListFile); 186 // flush last rows to database if existing 187 if (!empty($this->arrRows)) { 188 $this->connectionPool 189 ->getConnectionForTable(self::TABLE_NAME) 190 ->bulkInsert( 191 'tx_extensionmanager_domain_model_extension', 192 $this->arrRows, 193 self::$fieldNames 194 ); 195 } 196 $this->markExtensionWithMaximumVersionAsCurrent($remoteIdentifier); 197 } 198 199 /** 200 * Method collects and stores extension version details into the database. 201 * 202 * @param ExtensionXmlParser $subject a subject notifying this observer 203 */ 204 protected function loadIntoDatabase(ExtensionXmlParser $subject): void 205 { 206 if ($this->sumRecords !== 0 && $this->sumRecords % $this->maxRowsPerChunk === 0) { 207 $this->connectionPool 208 ->getConnectionForTable(self::TABLE_NAME) 209 ->bulkInsert( 210 self::TABLE_NAME, 211 $this->arrRows, 212 self::$fieldNames 213 ); 214 $this->arrRows = []; 215 } 216 if (!$subject->isValidVersionNumber()) { 217 // Skip in case extension version is not valid 218 return; 219 } 220 $versionRepresentations = VersionNumberUtility::convertVersionStringToArray($subject->getVersion()); 221 // order must match that of self::$fieldNames! 222 $this->arrRows[] = [ 223 $subject->getExtkey(), 224 $subject->getVersion(), 225 $versionRepresentations['version_int'], 226 // initialize current_version, correct value computed later: 227 0, 228 $subject->getAlldownloadcounter(), 229 $subject->getDownloadcounter(), 230 $subject->getTitle() ?? '', 231 $subject->getOwnerusername(), 232 $subject->getAuthorname() ?? '', 233 $subject->getAuthoremail() ?? '', 234 $subject->getAuthorcompany() ?? '', 235 $subject->getLastuploaddate(), 236 $subject->getT3xfilemd5(), 237 $this->remoteIdentifier, 238 $this->extensionModel->getDefaultState($subject->getState() ?: ''), 239 $subject->getReviewstate(), 240 $this->extensionModel->getCategoryIndexFromStringOrNumber($subject->getCategory() ?: ''), 241 $subject->getDescription() ?: '', 242 $subject->getDependencies() ?: '', 243 $subject->getUploadcomment() ?: '', 244 $subject->getDocumentationLink() ?: '', 245 $subject->getDistributionImage() ?: '', 246 $subject->getDistributionWelcomeImage() ?: '', 247 ]; 248 ++$this->sumRecords; 249 } 250 251 /** 252 * Method receives an update from a subject. 253 * 254 * @param \SplSubject $subject a subject notifying this observer 255 */ 256 public function update(\SplSubject $subject): void 257 { 258 if ($subject instanceof ExtensionXmlParser) { 259 if ((int)$subject->getLastuploaddate() > $this->minimumDateToImport) { 260 $this->loadIntoDatabase($subject); 261 } 262 } 263 } 264 265 /** 266 * Sets current_version = 1 for all extensions where the extension version is maximal. 267 * 268 * For performance reasons, the "native" database connection is used here directly. 269 * 270 * @param string $remoteIdentifier 271 */ 272 protected function markExtensionWithMaximumVersionAsCurrent(string $remoteIdentifier): void 273 { 274 $uidsOfCurrentVersion = $this->fetchMaximalVersionsForAllExtensions($remoteIdentifier); 275 $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME); 276 $connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME); 277 $maxBindParameters = PlatformInformation::getMaxBindParameters( 278 $connection->getDatabasePlatform() 279 ); 280 281 foreach (array_chunk($uidsOfCurrentVersion, $maxBindParameters - 10) as $chunk) { 282 $queryBuilder 283 ->update(self::TABLE_NAME) 284 ->where( 285 $queryBuilder->expr()->in( 286 'uid', 287 $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) 288 ) 289 ) 290 ->set('current_version', 1) 291 ->executeStatement(); 292 } 293 } 294 295 /** 296 * Fetches the UIDs of all maximal versions for all extensions. 297 * This is done by doing a LEFT JOIN to itself ("a" and "b") and comparing 298 * both integer_version fields. 299 * 300 * @param string $remoteIdentifier 301 * @return array 302 */ 303 protected function fetchMaximalVersionsForAllExtensions(string $remoteIdentifier): array 304 { 305 $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME); 306 307 $queryResult = $queryBuilder 308 ->select('a.uid AS uid') 309 ->from(self::TABLE_NAME, 'a') 310 ->leftJoin( 311 'a', 312 self::TABLE_NAME, 313 'b', 314 $queryBuilder->expr()->andX( 315 $queryBuilder->expr()->eq('a.remote', $queryBuilder->quoteIdentifier('b.remote')), 316 $queryBuilder->expr()->eq('a.extension_key', $queryBuilder->quoteIdentifier('b.extension_key')), 317 $queryBuilder->expr()->lt('a.integer_version', $queryBuilder->quoteIdentifier('b.integer_version')) 318 ) 319 ) 320 ->where( 321 $queryBuilder->expr()->eq( 322 'a.remote', 323 $queryBuilder->createNamedParameter($remoteIdentifier) 324 ), 325 $queryBuilder->expr()->isNull('b.extension_key') 326 ) 327 ->orderBy('a.uid') 328 ->executeQuery(); 329 330 $extensionUids = []; 331 while ($row = $queryResult->fetchAssociative()) { 332 $extensionUids[] = $row['uid']; 333 } 334 335 return $extensionUids; 336 } 337} 338