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