PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / trunk
Matomo Analytics – Powerful, Privacy-First Insights for WordPress vtrunk
5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / app / core / DataAccess / ArchiveSelector.php
matomo / app / core / DataAccess Last commit date
LogQueryBuilder 3 months ago Actions.php 6 months ago ArchiveSelector.php 1 month ago ArchiveTableCreator.php 1 month ago ArchiveTableDao.php 1 month ago ArchiveWriter.php 1 month ago ArchivingDbAdapter.php 1 year ago LogAggregator.php 1 month ago LogQueryBuilder.php 6 months ago LogTableTemporary.php 2 years ago Model.php 1 month ago RawLogDao.php 6 months ago TableMetadata.php 1 year ago
ArchiveSelector.php
590 lines
1 <?php
2
3 /**
4 * Matomo - free/libre analytics platform
5 *
6 * @link https://matomo.org
7 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8 */
9 namespace Piwik\DataAccess;
10
11 use Exception;
12 use Piwik\Archive;
13 use Piwik\Archive\Chunk;
14 use Piwik\ArchiveProcessor;
15 use Piwik\ArchiveProcessor\Rules;
16 use Piwik\Common;
17 use Piwik\Container\StaticContainer;
18 use Piwik\Date;
19 use Piwik\Db;
20 use Piwik\Period;
21 use Piwik\Period\Range;
22 use Piwik\Segment;
23 use Piwik\Log\LoggerInterface;
24 /**
25 * Data Access object used to query archives
26 *
27 * A record in the Database for a given report is defined by
28 * - idarchive = unique ID that is associated to all the data of this archive (idsite+period+date)
29 * - idsite = the ID of the website
30 * - date1 = starting day of the period
31 * - date2 = ending day of the period
32 * - period = integer that defines the period (day/week/etc.). @see period::getId()
33 * - ts_archived = timestamp when the archive was processed (UTC)
34 * - name = the name of the report (ex: uniq_visitors or search_keywords_by_search_engines)
35 * - value = the actual data (a numeric value, or a blob of compressed serialized data)
36 *
37 */
38 class ArchiveSelector
39 {
40 public const NB_VISITS_RECORD_LOOKED_UP = "nb_visits";
41 public const NB_VISITS_CONVERTED_RECORD_LOOKED_UP = "nb_visits_converted";
42 private static function getModel()
43 {
44 return new \Piwik\DataAccess\Model();
45 }
46 /**
47 * @param bool $minDatetimeArchiveProcessedUTC deprecated. Will be removed in Matomo 4.
48 * @return array An array with four values:
49 * - the latest archive ID or false if none
50 * - the latest visits value for the latest archive, regardless of whether the archive is invalidated or not
51 * - the latest visits converted value for the latest archive, regardless of whether the archive is invalidated or not
52 * - whether there is an archive that exists or not. if this is true and the latest archive is false, it means
53 * the archive found was not usable (for example, it was invalidated and we are not looking for invalidated archives)
54 * - the ts_archived for the latest usable archive
55 * @throws Exception
56 */
57 public static function getArchiveIdAndVisits(ArchiveProcessor\Parameters $params, $minDatetimeArchiveProcessedUTC = \false, $includeInvalidated = null)
58 {
59 $idSite = $params->getSite()->getId();
60 $period = $params->getPeriod()->getId();
61 $dateStart = $params->getPeriod()->getDateStart();
62 $dateStartIso = $dateStart->toString('Y-m-d');
63 $dateEndIso = $params->getPeriod()->getDateEnd()->toString('Y-m-d');
64 $numericTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($dateStart, \false);
65 if (empty($numericTable)) {
66 return self::archiveInfoBcResult(['idArchives' => \false, 'visits' => \false, 'visitsConverted' => \false, 'archiveExists' => \false, 'tsArchived' => \false, 'doneFlagValue' => \false, 'existingRecords' => null]);
67 }
68 $requestedPlugin = $params->getRequestedPlugin();
69 $requestedReport = $params->getArchiveOnlyReport();
70 $segment = $params->getSegment();
71 $plugins = array("VisitsSummary", $requestedPlugin);
72 $plugins = array_filter($plugins);
73 $doneFlags = Rules::getDoneFlags($plugins, $segment);
74 $requestedPluginDoneFlags = empty($requestedPlugin) ? [] : Rules::getDoneFlags([$requestedPlugin], $segment);
75 $allPluginsDoneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment);
76 $doneFlagValues = Rules::getSelectableDoneFlagValues($includeInvalidated === null ? \true : $includeInvalidated, $params, $includeInvalidated === null);
77 $results = self::getModel()->getArchiveIdAndVisits($numericTable, $idSite, $period, $dateStartIso, $dateEndIso, null, $doneFlags);
78 if (empty($results)) {
79 // no archive found
80 return self::archiveInfoBcResult(['idArchives' => \false, 'visits' => \false, 'visitsConverted' => \false, 'archiveExists' => \false, 'tsArchived' => \false, 'doneFlagValue' => \false, 'existingRecords' => null]);
81 }
82 $result = self::findArchiveDataWithLatestTsArchived($results, $requestedPluginDoneFlags, $allPluginsDoneFlag);
83 $tsArchived = isset($result['ts_archived']) ? $result['ts_archived'] : \false;
84 $visits = isset($result['nb_visits']) ? $result['nb_visits'] : \false;
85 $visitsConverted = isset($result['nb_visits_converted']) ? $result['nb_visits_converted'] : \false;
86 $value = isset($result['value']) ? $result['value'] : \false;
87 $existingRecords = null;
88 $result['idarchive'] = empty($result['idarchive']) ? [] : [$result['idarchive']];
89 if (!empty($result['partial'])) {
90 // when we are not looking for a specific report, or if we have found a non-partial archive
91 // that we expect to have the full set of reports for the requested plugin, then we can just
92 // return it with the additionally found partial archives.
93 //
94 // if, however, there is no full archive, and only a set of partial archives, then
95 // we have to check whether the requested data is actually within them. if we just report the
96 // partial archives, Archive.php will find no archive data and simply report this. returning no
97 // idarchive here, however, will initiate archiving, causing the missing data to populate.
98 if (empty($requestedReport) || !empty($result['idarchive'])) {
99 $result['idarchive'] = array_merge($result['idarchive'], $result['partial']);
100 } else {
101 $existingRecords = self::getModel()->getRecordsContainedInArchives($dateStart, $result['partial'], $requestedReport);
102 if (!empty($existingRecords)) {
103 $result['idarchive'] = array_merge($result['idarchive'], $result['partial']);
104 }
105 }
106 }
107 if (empty($result['idarchive']) || isset($result['value']) && !in_array($result['value'], $doneFlagValues)) {
108 // the archive cannot be considered valid for this request (has wrong done flag value)
109 return self::archiveInfoBcResult(['idArchives' => \false, 'visits' => $visits, 'visitsConverted' => $visitsConverted, 'archiveExists' => \true, 'tsArchived' => $tsArchived, 'doneFlagValue' => $value, 'existingRecords' => null]);
110 }
111 if (!empty($minDatetimeArchiveProcessedUTC) && !is_object($minDatetimeArchiveProcessedUTC)) {
112 $minDatetimeArchiveProcessedUTC = Date::factory($minDatetimeArchiveProcessedUTC);
113 }
114 // the archive is too old
115 if ($minDatetimeArchiveProcessedUTC && !empty($result['idarchive']) && Date::factory($tsArchived)->isEarlier($minDatetimeArchiveProcessedUTC)) {
116 return self::archiveInfoBcResult(['idArchives' => \false, 'visits' => $visits, 'visitsConverted' => $visitsConverted, 'archiveExists' => \true, 'tsArchived' => $tsArchived, 'doneFlagValue' => $value, 'existingRecords' => null]);
117 }
118 $idArchives = !empty($result['idarchive']) ? $result['idarchive'] : \false;
119 return self::archiveInfoBcResult(['idArchives' => $idArchives, 'visits' => $visits, 'visitsConverted' => $visitsConverted, 'archiveExists' => \true, 'tsArchived' => $tsArchived, 'doneFlagValue' => $value, 'existingRecords' => $existingRecords]);
120 }
121 /**
122 * Queries and returns archive IDs for a set of sites, periods, and a segment.
123 *
124 * @param int[] $siteIds
125 * @param Period[] $periods
126 * @param Segment $segment
127 * @param string[] $plugins List of plugin names for which data is being requested.
128 * @param bool $includeInvalidated true to include archives that are DONE_INVALIDATED, false if only DONE_OK.
129 * @param bool $_skipSetGroupConcatMaxLen for tests
130 * @return array Archive IDs are grouped by archive name and period range, ie,
131 * array(
132 * 'VisitsSummary.done' => array(
133 * '2010-01-01' => array(1,2,3)
134 * )
135 * )
136 * @throws
137 */
138 public static function getArchiveIds($siteIds, $periods, $segment, $plugins, $includeInvalidated = \true, $_skipSetGroupConcatMaxLen = \false)
139 {
140 return self::getArchiveIdsAndStates($siteIds, $periods, $segment, $plugins, $includeInvalidated, $_skipSetGroupConcatMaxLen)[0];
141 }
142 /**
143 * Queries and returns archive IDs and the associated doneFlag
144 * values for a set of sites, periods, and a segment.
145 *
146 * @param int[] $siteIds
147 * @param Period[] $periods
148 * @param Segment $segment
149 * @param string[] $plugins List of plugin names for which data is being requested.
150 * @param bool $includeInvalidated true to include archives that are DONE_INVALIDATED, false if only DONE_OK.
151 * @param bool $_skipSetGroupConcatMaxLen for tests
152 *
153 * @return array Archive IDs are grouped by archive name and period range, ie,
154 * array(
155 * array(
156 * 'VisitsSummary.done' => array(
157 * '2010-01-01' => array(1,2,3)
158 * )
159 * )
160 * array(
161 * 100 => array(
162 * 'VisitsSummary.done' => array(
163 * '2010-01-01' => array(
164 * 1 => 1,
165 * 2 => 4,
166 * 3 => 5
167 * )
168 * )
169 * )
170 * )
171 * )
172 * @throws
173 */
174 public static function getArchiveIdsAndStates($siteIds, $periods, $segment, $plugins, $includeInvalidated = \true, $_skipSetGroupConcatMaxLen = \false) : array
175 {
176 $logger = StaticContainer::get(LoggerInterface::class);
177 if (!$_skipSetGroupConcatMaxLen) {
178 try {
179 Db::get()->query('SET SESSION group_concat_max_len=' . 128 * 1024);
180 } catch (\Exception $ex) {
181 $logger->info("Could not set group_concat_max_len MySQL session variable.");
182 }
183 }
184 if (empty($siteIds)) {
185 throw new \Exception("Website IDs could not be read from the request, ie. idSite=");
186 }
187 foreach ($siteIds as $index => $siteId) {
188 $siteIds[$index] = (int) $siteId;
189 }
190 $getArchiveIdsSql = "SELECT idsite, date1, date2,\n GROUP_CONCAT(CONCAT(idarchive,'|',`name`,'|',`value`) ORDER BY idarchive DESC SEPARATOR ',') AS archives\n FROM `%s`\n WHERE idsite IN (" . implode(',', $siteIds) . ")\n AND " . self::getNameCondition($plugins, $segment, $includeInvalidated) . "\n AND %s\n GROUP BY idsite, date1, date2";
191 $monthToPeriods = array();
192 foreach ($periods as $period) {
193 /** @var Period $period */
194 if ($period->getDateStart()->isLater(Date::now()->addDay(2))) {
195 continue;
196 // avoid creating any archive tables in the future
197 }
198 $table = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($period->getDateStart(), \false);
199 if (empty($table)) {
200 continue;
201 }
202 $monthToPeriods[$table][] = $period;
203 }
204 $db = Db::get();
205 // for every month within the archive query, select from numeric table
206 $idarchives = [];
207 $idarchiveStates = [];
208 foreach ($monthToPeriods as $table => $periods) {
209 $firstPeriod = reset($periods);
210 $bind = [];
211 if ($firstPeriod instanceof Range) {
212 $dateCondition = "date1 = ? AND date2 = ?";
213 $bind[] = $firstPeriod->getDateStart()->toString('Y-m-d');
214 $bind[] = $firstPeriod->getDateEnd()->toString('Y-m-d');
215 } else {
216 // we assume there is no range date in $periods
217 $dateCondition = '(';
218 foreach ($periods as $period) {
219 if (strlen($dateCondition) > 1) {
220 $dateCondition .= ' OR ';
221 }
222 $dateCondition .= "(period = ? AND date1 = ? AND date2 = ?)";
223 $bind[] = $period->getId();
224 $bind[] = $period->getDateStart()->toString('Y-m-d');
225 $bind[] = $period->getDateEnd()->toString('Y-m-d');
226 }
227 $dateCondition .= ')';
228 }
229 $sql = sprintf($getArchiveIdsSql, $table, $dateCondition);
230 $archiveIds = $db->fetchAll($sql, $bind);
231 // get the archive IDs. we keep all archives until the first all plugins archive.
232 // everything older than that one is discarded.
233 foreach ($archiveIds as $row) {
234 $dateStr = $row['date1'] . ',' . $row['date2'];
235 $idSite = $row['idsite'];
236 $archives = $row['archives'];
237 $pairs = explode(',', $archives);
238 foreach ($pairs as $pair) {
239 $parts = explode('|', $pair);
240 if (count($parts) != 3) {
241 // GROUP_CONCAT got cut off, have to ignore the rest
242 // note: in this edge case, we end up not selecting the all plugins archive because it will be older than the partials.
243 // not ideal, but it avoids an exception.
244 $logger->info("GROUP_CONCAT got cut off in ArchiveSelector." . __FUNCTION__ . ' for idsite = ' . $idSite . ', period = ' . $dateStr);
245 continue;
246 }
247 [$idarchive, $doneFlag, $value] = $parts;
248 $idarchives[$doneFlag][$dateStr][] = $idarchive;
249 $idarchiveStates[$idSite][$doneFlag][$dateStr][$idarchive] = (int) $value;
250 if (strpos($doneFlag, '.') === \false && $value != \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL) {
251 break;
252 // found the all plugins archive, don't need to look in older archives since we have everything here
253 }
254 }
255 }
256 }
257 return [$idarchives, $idarchiveStates];
258 }
259 /**
260 * Queries and returns archive data using a set of archive IDs.
261 *
262 * @param array $archiveIds The IDs of the archives to get data from.
263 * @param array $recordNames The names of the data to retrieve (ie, nb_visits, nb_actions, etc.).
264 * Note: You CANNOT pass multiple recordnames if $loadAllSubtables=true.
265 * @param string $archiveDataType The archive data type (either, 'blob' or 'numeric').
266 * @param int|null|string $idSubtable null if the root blob should be loaded, an integer if a subtable should be
267 * loaded and 'all' if all subtables should be loaded.
268 * @return array
269 *@throws Exception
270 */
271 public static function getArchiveData($archiveIds, $recordNames, $archiveDataType, $idSubtable)
272 {
273 $chunk = new Chunk();
274 $db = Db::get();
275 $loadAllSubtables = $idSubtable === Archive::ID_SUBTABLE_LOAD_ALL_SUBTABLES;
276 [$getValuesSql, $bind] = self::getSqlTemplateToFetchArchiveData($recordNames, $idSubtable);
277 $archiveIdsPerMonth = self::getArchiveIdsByYearMonth($archiveIds);
278 // get data from every table we're querying
279 $rows = array();
280 foreach ($archiveIdsPerMonth as $yearMonth => $ids) {
281 if (empty($ids)) {
282 throw new Exception("Unexpected: id archive not found for period '{$yearMonth}' '");
283 }
284 // $yearMonth = "2022-11",
285 $date = Date::factory($yearMonth . '-01');
286 $isNumeric = $archiveDataType === 'numeric';
287 if ($isNumeric) {
288 $table = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($date, \false);
289 } else {
290 $table = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($date, \false);
291 }
292 if (empty($table)) {
293 continue;
294 }
295 $ids = array_map('intval', $ids);
296 $sql = sprintf($getValuesSql, $table, implode(',', $ids));
297 $dataRows = $db->fetchAll($sql, $bind);
298 foreach ($dataRows as $row) {
299 if ($isNumeric) {
300 $rows[] = $row;
301 } else {
302 $row['value'] = self::uncompress($row['value']);
303 if ($chunk->isRecordNameAChunk($row['name'])) {
304 self::moveChunkRowToRows($rows, $row, $chunk, $loadAllSubtables, $idSubtable);
305 } else {
306 $rows[] = $row;
307 }
308 }
309 }
310 }
311 return $rows;
312 }
313 private static function moveChunkRowToRows(&$rows, $row, Chunk $chunk, $loadAllSubtables, $idSubtable)
314 {
315 // $blobs = array([subtableID] = [blob of subtableId])
316 $blobs = Common::safe_unserialize($row['value']);
317 if (!is_array($blobs)) {
318 return;
319 }
320 // $rawName = eg 'PluginName_ArchiveName'
321 $rawName = $chunk->getRecordNameWithoutChunkAppendix($row['name']);
322 if ($loadAllSubtables) {
323 foreach ($blobs as $subtableId => $blob) {
324 $row['value'] = $blob;
325 $row['name'] = self::appendIdSubtable($rawName, $subtableId);
326 $rows[] = $row;
327 }
328 } elseif (array_key_exists($idSubtable, $blobs)) {
329 $row['value'] = $blobs[$idSubtable];
330 $row['name'] = self::appendIdSubtable($rawName, $idSubtable);
331 $rows[] = $row;
332 }
333 }
334 public static function appendIdSubtable($recordName, $id)
335 {
336 return $recordName . "_" . $id;
337 }
338 public static function uncompress($data)
339 {
340 return @gzuncompress($data);
341 }
342 /**
343 * Returns the SQL condition used to find successfully completed archives that
344 * this instance is querying for.
345 *
346 * @param array $plugins
347 * @param bool $includeInvalidated
348 * @return string
349 */
350 private static function getNameCondition(array $plugins, Segment $segment, $includeInvalidated = \true)
351 {
352 // the flags used to tell how the archiving process for a specific archive was completed,
353 // if it was completed
354 $doneFlags = Rules::getDoneFlags($plugins, $segment);
355 $allDoneFlags = "'" . implode("','", $doneFlags) . "'";
356 $possibleValues = Rules::getSelectableDoneFlagValues($includeInvalidated, null, $checkAuthorizedToArchive = \false);
357 // create the SQL to find archives that are DONE
358 return "((name IN ({$allDoneFlags})) AND (value IN (" . implode(',', $possibleValues) . ")))";
359 }
360 /**
361 * This method takes the output of Model::getArchiveIdAndVisits() and selects data from the
362 * latest archives.
363 *
364 * This includes:
365 * - the idarchive with the latest ts_archived ($results will be ordered by ts_archived desc)
366 * - the visits/converted visits of the latest archive, which includes archives for VisitsSummary alone
367 * ($requestedPluginDoneFlags will have the done flag for the overall archive plus a done flag for
368 * VisitsSummary by itself)
369 * - the ts_archived for the latest idarchive
370 * - the doneFlag value for the latest archive
371 *
372 * @param $results
373 * @param $doneFlags
374 * @return array
375 */
376 private static function findArchiveDataWithLatestTsArchived($results, $requestedPluginDoneFlags, $allPluginsDoneFlag)
377 {
378 $doneFlags = array_merge($requestedPluginDoneFlags, [$allPluginsDoneFlag]);
379 // find latest idarchive for each done flag
380 $idArchives = [];
381 $tsArchiveds = [];
382 foreach ($results as $row) {
383 $doneFlag = $row['name'];
384 if (!isset($idArchives[$doneFlag])) {
385 $idArchives[$doneFlag] = $row['idarchive'];
386 $tsArchiveds[$doneFlag] = $row['ts_archived'];
387 }
388 }
389 $archiveData = [self::NB_VISITS_RECORD_LOOKED_UP => \false, self::NB_VISITS_CONVERTED_RECORD_LOOKED_UP => \false];
390 foreach ($results as $result) {
391 if (in_array($result['name'], $doneFlags) && in_array($result['idarchive'], $idArchives) && $result['value'] != \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL) {
392 $archiveData = $result;
393 if (empty($archiveData[self::NB_VISITS_RECORD_LOOKED_UP])) {
394 $archiveData[self::NB_VISITS_RECORD_LOOKED_UP] = 0;
395 }
396 if (empty($archiveData[self::NB_VISITS_CONVERTED_RECORD_LOOKED_UP])) {
397 $archiveData[self::NB_VISITS_CONVERTED_RECORD_LOOKED_UP] = 0;
398 }
399 break;
400 }
401 }
402 foreach ([self::NB_VISITS_RECORD_LOOKED_UP, self::NB_VISITS_CONVERTED_RECORD_LOOKED_UP] as $metric) {
403 foreach ($results as $result) {
404 if (!in_array($result['idarchive'], $idArchives)) {
405 continue;
406 }
407 if (empty($archiveData[$metric])) {
408 if (!empty($result[$metric]) || $result[$metric] === 0 || $result[$metric] === 0.0 || $result[$metric] === '0') {
409 $archiveData[$metric] = $result[$metric];
410 }
411 }
412 }
413 }
414 // add partial archives
415 $mainTsArchived = isset($tsArchiveds[$allPluginsDoneFlag]) ? $tsArchiveds[$allPluginsDoneFlag] : null;
416 foreach ($results as $row) {
417 if (!isset($idArchives[$row['name']])) {
418 continue;
419 }
420 $thisTsArchived = Date::factory($row['ts_archived']);
421 if ($row['value'] == \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL && (empty($mainTsArchived) || !Date::factory($mainTsArchived)->isLater($thisTsArchived))) {
422 $archiveData['partial'][] = $row['idarchive'];
423 if (empty($archiveData['ts_archived'])) {
424 $archiveData['ts_archived'] = $row['ts_archived'];
425 }
426 }
427 }
428 return $archiveData;
429 }
430 /**
431 * provides BC result for getArchiveIdAndVisits
432 * @param array $archiveInfo
433 * @return array
434 */
435 private static function archiveInfoBcResult(array $archiveInfo)
436 {
437 $archiveInfo[0] = $archiveInfo['idArchives'];
438 $archiveInfo[1] = $archiveInfo['visits'];
439 $archiveInfo[2] = $archiveInfo['visitsConverted'];
440 $archiveInfo[3] = $archiveInfo['archiveExists'];
441 $archiveInfo[4] = $archiveInfo['tsArchived'];
442 $archiveInfo[5] = $archiveInfo['doneFlagValue'];
443 return $archiveInfo;
444 }
445 public static function querySingleBlob(array $archiveIds, string $recordName)
446 {
447 $chunk = new Chunk();
448 [$getValuesSql, $bind] = self::getSqlTemplateToFetchArchiveData([$recordName], Archive::ID_SUBTABLE_LOAD_ALL_SUBTABLES, \true);
449 $archiveIdsPerMonth = self::getArchiveIdsByYearMonth($archiveIds);
450 $periodsSeen = [];
451 // $yearMonth = "2022-11",
452 foreach ($archiveIdsPerMonth as $yearMonth => $ids) {
453 $date = Date::factory($yearMonth . '-01');
454 $table = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($date, \false);
455 if (empty($table)) {
456 continue;
457 }
458 $ids = array_map('intval', $ids);
459 $sql = sprintf($getValuesSql, $table, implode(',', $ids));
460 $cursor = Db::get()->query($sql, $bind);
461 while ($row = $cursor->fetch()) {
462 $period = $row['date1'] . ',' . $row['date2'];
463 $recordName = $row['name'];
464 // FIXME: This hack works around a strange bug that occurs when getting
465 // archive IDs through ArchiveProcessing instances. When a table
466 // does not already exist, for some reason the archive ID for
467 // today (or from two days ago) will be added to the Archive
468 // instances list. The Archive instance will then select data
469 // for periods outside of the requested set.
470 // working around the bug here, but ideally, we need to figure
471 // out why incorrect idarchives are being selected.
472 if (empty($archiveIds[$period])) {
473 continue;
474 }
475 // only use the first period/blob name combination seen (since we order by ts_archived descending)
476 if (!empty($periodsSeen[$period][$recordName])) {
477 continue;
478 }
479 $periodsSeen[$period][$recordName] = \true;
480 $row['value'] = \Piwik\DataAccess\ArchiveSelector::uncompress($row['value']);
481 if ($chunk->isRecordNameAChunk($row['name'])) {
482 // $blobs = array([subtableID] = [blob of subtableId])
483 $blobs = Common::safe_unserialize($row['value']);
484 if (!is_array($blobs)) {
485 (yield $row);
486 }
487 ksort($blobs);
488 // $rawName = eg 'PluginName_ArchiveName'
489 $rawName = $chunk->getRecordNameWithoutChunkAppendix($row['name']);
490 foreach ($blobs as $subtableId => $blob) {
491 unset($blobs[$subtableId]);
492 (yield array_merge($row, ['value' => $blob, 'name' => \Piwik\DataAccess\ArchiveSelector::appendIdSubtable($rawName, $subtableId)]));
493 }
494 } else {
495 (yield $row);
496 }
497 }
498 }
499 }
500 /**
501 * Returns SQL to fetch data from an archive table. The SQL has two %s placeholders, one for the
502 * archive table name and another for the comma separated list of archive IDs to look for.
503 *
504 * @param array $recordNames The list of records to look for.
505 * @param string|int $idSubtable The idSubtable to look for or 'all' to load all of them.
506 * @param boolean $orderBySubtableId If true, orders the result set by start date ascending, subtable ID
507 * ascending and ts_archived descending. Only applied if loading all
508 * subtables for a single record.
509 *
510 * This parameter is used when aggregating blob data for a single record
511 * without loading entire datatable trees in memory.
512 * @return array The sql and bind values.
513 */
514 private static function getSqlTemplateToFetchArchiveData(array $recordNames, $idSubtable, $orderBySubtableId = \false)
515 {
516 $chunk = new Chunk();
517 $orderBy = 'ORDER BY ts_archived ASC';
518 // create the SQL to select archive data
519 $loadAllSubtables = $idSubtable === Archive::ID_SUBTABLE_LOAD_ALL_SUBTABLES;
520 if ($loadAllSubtables) {
521 $name = reset($recordNames);
522 // select blobs w/ name like "$name_[0-9]+" w/o using RLIKE
523 $nameEnd = strlen($name) + 1;
524 $nameEndAppendix = $nameEnd + 1;
525 $appendix = $chunk->getAppendix();
526 $lenAppendix = strlen($appendix);
527 $checkForChunkBlob = "SUBSTRING(name, {$nameEnd}, {$lenAppendix}) = '{$appendix}'";
528 $checkForSubtableId = "(SUBSTRING(name, {$nameEndAppendix}, 1) >= '0'\n AND SUBSTRING(name, {$nameEndAppendix}, 1) <= '9')";
529 $whereNameIs = "(name = ? OR (name LIKE ? AND ( {$checkForChunkBlob} OR {$checkForSubtableId} ) ))";
530 $bind = array($name, addcslashes($name, '%_') . '%');
531 if ($orderBySubtableId && count($recordNames) == 1) {
532 $idSubtableAsInt = self::getExtractIdSubtableFromBlobNameSql($chunk, $name);
533 $orderBy = "ORDER BY date1 ASC, " . " {$idSubtableAsInt} ASC,\n ts_archived DESC";
534 // ascending order so we use the latest data found
535 }
536 } else {
537 if ($idSubtable === null) {
538 // select root table or specific record names
539 $bind = array_values($recordNames);
540 } else {
541 // select a subtable id
542 $bind = array();
543 foreach ($recordNames as $recordName) {
544 // to be backwards compatible we need to look for the exact idSubtable blob and for the chunk
545 // that stores the subtables (a chunk stores many blobs in one blob)
546 $bind[] = $chunk->getRecordNameForTableId($recordName, $idSubtable);
547 $bind[] = self::appendIdSubtable($recordName, $idSubtable);
548 }
549 }
550 $inNames = Common::getSqlStringFieldsArray($bind);
551 $whereNameIs = "name IN ({$inNames})";
552 }
553 $getValuesSql = "SELECT value, name, idsite, date1, date2, ts_archived\n FROM `%s`\n WHERE idarchive IN (%s)\n AND " . $whereNameIs . "\n {$orderBy}";
554 // ascending order so we use the latest data found
555 return [$getValuesSql, $bind];
556 }
557 private static function getArchiveIdsByYearMonth(array $archiveIds)
558 {
559 // We want to fetch as many archives at once as possible instead of fetching each period individually
560 // eg instead of issueing one query per day we'll merge all the IDs of a given month into one query
561 // we group by YYYY-MM as we have one archive table per month
562 $archiveIdsPerMonth = [];
563 foreach ($archiveIds as $period => $ids) {
564 $yearMonth = substr($period, 0, 7);
565 // eg 2022-11
566 if (empty($archiveIdsPerMonth[$yearMonth])) {
567 $archiveIdsPerMonth[$yearMonth] = [];
568 }
569 $archiveIdsPerMonth[$yearMonth] = array_merge($archiveIdsPerMonth[$yearMonth], $ids);
570 }
571 return $archiveIdsPerMonth;
572 }
573 // public for tests
574 public static function getExtractIdSubtableFromBlobNameSql(Chunk $chunk, $name)
575 {
576 // select blobs w/ name like "$name_[0-9]+" w/o using RLIKE
577 $nameEnd = strlen($name) + 1;
578 $nameEndAfterUnderscore = $nameEnd + 1;
579 $appendix = $chunk->getAppendix();
580 $lenAppendix = strlen($appendix);
581 $chunkEnd = $nameEnd + $lenAppendix;
582 $checkForChunkBlob = "SUBSTRING(name, {$nameEnd}, {$lenAppendix}) = '{$appendix}'";
583 $extractSuffix = "SUBSTRING(name, IF({$checkForChunkBlob}, {$chunkEnd}, {$nameEndAfterUnderscore}))";
584 $locateSecondUnderscore = "IF((@secondunderscore := LOCATE('_', {$extractSuffix}) - 1) < 0, LENGTH(name), @secondunderscore)";
585 $extractIdSubtableStart = "IF( (@idsubtable := SUBSTRING({$extractSuffix}, 1, {$locateSecondUnderscore})) = '', -1, @idsubtable )";
586 $idSubtableAsInt = "CAST({$extractIdSubtableStart} AS SIGNED)";
587 return $idSubtableAsInt;
588 }
589 }
590