PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.13.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.13.0
5.13.0 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 / Segment.php
matomo / app / core Last commit date
API 4 days ago Access 1 month ago Application 1 month ago Archive 1 month ago ArchiveProcessor 4 days ago Archiver 2 years ago AssetManager 1 month ago Auth 1 month ago Category 1 month ago Changes 3 months ago CliMulti 1 month ago Columns 4 days ago Concurrency 1 month ago Config 1 month ago Container 3 months ago CronArchive 1 month ago DataAccess 4 days ago DataFiles 2 years ago DataTable 1 month ago Db 4 days ago DeviceDetector 1 year ago Email 2 years ago Exception 6 months ago Http 4 days ago Intl 5 months ago Log 2 years ago Mail 1 year ago Measurable 8 months ago Menu 1 month ago Metrics 1 month ago Notification 8 months ago Period 1 month ago Plugin 4 days ago Policy 3 months ago ProfessionalServices 1 year ago Report 1 year ago ReportRenderer 4 days ago Request 1 month ago Scheduler 1 month ago Segment 1 month ago Session 4 days ago Settings 4 days ago Tracker 4 days ago Translation 3 months ago Twig 1 year ago UpdateCheck 5 months ago Updater 1 month ago Updates 2 months ago Validators 1 year ago View 3 months ago ViewDataTable 4 days ago Visualization 4 days ago Widget 1 month ago .htaccess 2 years ago Access.php 1 month ago Archive.php 3 months ago ArchiveProcessor.php 4 days ago AssetManager.php 1 month ago Auth.php 8 months ago AuthResult.php 8 months ago BaseFactory.php 2 years ago Cache.php 1 month ago CacheId.php 6 months ago CliMulti.php 1 month ago Common.php 1 month ago Config.php 1 month ago Console.php 5 months ago Context.php 2 years ago Cookie.php 1 month ago CronArchive.php 1 month ago DI.php 5 months ago DataArray.php 1 month ago DataTable.php 4 days ago Date.php 3 months ago Db.php 3 months ago DbHelper.php 1 month ago Development.php 1 month ago ErrorHandler.php 8 months ago EventDispatcher.php 3 months ago ExceptionHandler.php 6 months ago FileIntegrity.php 1 month ago Filechecks.php 1 year ago Filesystem.php 4 days ago FrontController.php 1 month ago Http.php 4 days ago IP.php 1 year ago Log.php 5 months ago LogDeleter.php 1 month ago Mail.php 1 year ago Metrics.php 3 months ago NoAccessException.php 2 years ago Nonce.php 8 months ago Notification.php 3 months ago NumberFormatter.php 1 month ago Option.php 1 month ago Period.php 1 month ago Piwik.php 1 month ago Plugin.php 3 months ago Process.php 3 months ago Profiler.php 1 month ago ProxyHeaders.php 6 months ago ProxyHttp.php 1 month ago QuickForm2.php 5 months ago RankingQuery.php 3 months ago ReportRenderer.php 4 days ago Request.php 3 months ago Segment.php 4 days ago Sequence.php 1 month ago Session.php 2 months ago SettingsPiwik.php 1 month ago SettingsServer.php 1 month ago Singleton.php 2 years ago Site.php 3 months ago SiteContentDetector.php 4 days ago SupportedBrowser.php 2 years ago TCPDF.php 1 year ago Theme.php 1 month ago Timer.php 1 month ago Tracker.php 1 month ago Twig.php 4 days ago Unzip.php 1 year ago UpdateCheck.php 3 months ago Updater.php 1 month ago UpdaterErrorException.php 2 years ago Updates.php 1 month ago Url.php 1 month ago UrlHelper.php 3 months ago Version.php 4 days ago View.php 1 month ago bootstrap.php 1 year ago dispatch.php 2 years ago testMinimumPhpVersion.php 8 months ago
Segment.php
645 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;
10
11 use Exception;
12 use Piwik\API\Request;
13 use Piwik\ArchiveProcessor\Rules;
14 use Piwik\Cache as PiwikCache;
15 use Piwik\Container\StaticContainer;
16 use Piwik\DataAccess\LogQueryBuilder;
17 use Piwik\Plugins\SegmentEditor\SegmentEditor;
18 use Piwik\Segment\SegmentExpression;
19 use Piwik\Plugins\SegmentEditor\Model as SegmentEditorModel;
20 use Piwik\Segment\SegmentsList;
21 /**
22 * Limits the set of visits Piwik uses when aggregating analytics data.
23 *
24 * A segment is a condition used to filter visits. They can, for example,
25 * select visits that have a specific browser or come from a specific
26 * country, or both.
27 *
28 * Plugins that aggregate data stored in Piwik can support segments by
29 * using this class when generating aggregation SQL queries.
30 *
31 * ### Examples
32 *
33 * **Basic usage**
34 *
35 * $idSites = array(1,2,3);
36 * $segmentStr = "browserCode==ff;countryCode==CA";
37 * $segment = new Segment($segmentStr, $idSites);
38 *
39 * $query = $segment->getSelectQuery(
40 * $select = "table.col1, table2.col2",
41 * $from = array("table", "table2"),
42 * $where = "table.col3 = ?",
43 * $bind = array(5),
44 * $orderBy = "table.col1 DESC",
45 * $groupBy = "table2.col2"
46 * );
47 *
48 * Db::fetchAll($query['sql'], $query['bind']);
49 *
50 * **Creating a _null_ segment**
51 *
52 * $idSites = array(1,2,3);
53 * $segment = new Segment('', $idSites);
54 * // $segment->getSelectQuery will return a query that selects all visits
55 *
56 * @api
57 */
58 class Segment
59 {
60 /**
61 * @var SegmentExpression
62 */
63 protected $segmentExpression = null;
64 /**
65 * @var string
66 */
67 protected $string = null;
68 /**
69 * @var string
70 */
71 protected $originalString = null;
72 /**
73 * @var array
74 */
75 protected $idSites = null;
76 /**
77 * @var Date
78 */
79 protected $startDate = null;
80 /**
81 * @var Date
82 */
83 protected $endDate = null;
84 /**
85 * @var LogQueryBuilder
86 */
87 private $segmentQueryBuilder;
88 /**
89 * @var bool
90 */
91 private $isSegmentEncoded;
92 /**
93 * @var Exception|null
94 */
95 private $missingDatesException = null;
96 /**
97 * Truncate the Segments to 8k
98 */
99 public const SEGMENT_TRUNCATE_LIMIT = 8192;
100 public const CACHE_KEY = 'segmenthashes';
101 public const SEGMENT_HAS_BUILT_CACHE_KEY = 'segmenthashbuilt';
102 /**
103 * When using segments that contain a != or !@ condition on a non visit dimension (e.g. action, conversion, ...) it
104 * is needed to use a subquery to get correct results. To avoid subqueries that fetch too many data it's required to
105 * set a startDate and/or an endDate in this case. That date will be used to limit the subquery (along with possibly
106 * given idSites). If no startDate and endDate is given for such a segment it will generate a query that directly
107 * joins the according tables, but trigger a php warning as results might be incorrect.
108 *
109 * @param string $segmentCondition The segment condition, eg, `'browserCode=ff;countryCode=CA'`.
110 * @param array $idSites The list of sites the segment will be used with. Some segments are
111 * dependent on the site, such as goal segments.
112 * @param Date|null $startDate start date used to limit subqueries
113 * @param Date|null $endDate end date used to limit subqueries
114 */
115 public function __construct($segmentCondition, $idSites, ?\Piwik\Date $startDate = null, ?\Piwik\Date $endDate = null)
116 {
117 $this->segmentQueryBuilder = StaticContainer::get('Piwik\\DataAccess\\LogQueryBuilder');
118 $segmentCondition = trim($segmentCondition ?: '');
119 if (!\Piwik\SettingsPiwik::isSegmentationEnabled() && !empty($segmentCondition)) {
120 throw new Exception("The Super User has disabled the Segmentation feature.");
121 }
122 $this->originalString = $segmentCondition;
123 if ($startDate instanceof \Piwik\Date) {
124 $this->startDate = $startDate;
125 }
126 if ($endDate instanceof \Piwik\Date) {
127 $this->endDate = $endDate;
128 }
129 // The segment expression can be urlencoded. Unfortunately, both the encoded and decoded versions
130 // can usually be parsed successfully. To pick the right one, we try both and pick the one w/ more
131 // successfully parsed subexpressions.
132 $subexpressionsDecoded = 0;
133 if (urldecode($segmentCondition) !== $segmentCondition) {
134 try {
135 $this->initializeSegment(urldecode($segmentCondition), $idSites);
136 $subexpressionsDecoded = $this->segmentExpression->getSubExpressionCount();
137 } catch (Exception $e) {
138 // ignore
139 }
140 }
141 $subexpressionsRaw = 0;
142 try {
143 $this->initializeSegment($segmentCondition, $idSites);
144 $subexpressionsRaw = $this->segmentExpression->getSubExpressionCount();
145 } catch (Exception $e) {
146 // ignore
147 }
148 if ($subexpressionsRaw > $subexpressionsDecoded) {
149 // segment initialized above
150 $this->isSegmentEncoded = \false;
151 } else {
152 $this->initializeSegment(urldecode($segmentCondition), $idSites);
153 $this->isSegmentEncoded = \true;
154 }
155 }
156 /**
157 * Checks if the provided segmentCondition is valid and available for the given idSites
158 *
159 * @params array $idSites
160 * @api since Matomo 5.3.0
161 */
162 public static function isAvailable(string $segmentCondition, array $idSites) : bool
163 {
164 try {
165 new self($segmentCondition, $idSites);
166 } catch (Exception $e) {
167 return \false;
168 }
169 return \true;
170 }
171 /**
172 * Returns the segment expression.
173 * @return SegmentExpression
174 * @api since Piwik 3.2.0
175 */
176 public function getSegmentExpression()
177 {
178 return $this->segmentExpression;
179 }
180 /**
181 * @throws Exception
182 */
183 private function getAvailableSegments()
184 {
185 // start cache
186 $cache = PiwikCache::getTransientCache();
187 //covert cache id
188 $cacheId = 'API.getSegmentsMetadata.' . \Piwik\SettingsPiwik::getPiwikInstanceId() . '.' . implode(",", $this->idSites);
189 //fetch cache lockId
190 $availableSegments = $cache->fetch($cacheId);
191 // segment metadata
192 if (empty($availableSegments)) {
193 $availableSegments = Request::processRequest('API.getSegmentsMetadata', array('idSites' => $this->idSites, '_hideImplementationData' => 0, 'filter_limit' => -1, 'filter_offset' => 0, '_showAllSegments' => 1), []);
194 // index by segment name
195 $availableSegments = array_column($availableSegments, null, 'segment');
196 // remove segments we don't have permission to use
197 foreach ($availableSegments as $segment => $segmentInfo) {
198 if (isset($segmentInfo['permission']) && $segmentInfo['permission'] != 1) {
199 $availableSegments[$segment] = null;
200 }
201 }
202 $cache->save($cacheId, $availableSegments);
203 }
204 return $availableSegments;
205 }
206 private function getSegmentByName($name)
207 {
208 $segments = $this->getAvailableSegments();
209 if (array_key_exists($name, $segments)) {
210 if ($segments[$name] === null) {
211 throw new \Piwik\NoAccessException("You do not have enough permission to access the segment " . $name);
212 }
213 return $segments[$name];
214 }
215 throw new Exception("Segment '{$name}' is not a supported segment.");
216 }
217 /**
218 * @param $string
219 * @param $idSites
220 * @throws Exception
221 */
222 protected function initializeSegment($string, $idSites)
223 {
224 // As a preventive measure, we restrict the filter size to a safe limit
225 $string = substr($string, 0, self::SEGMENT_TRUNCATE_LIMIT);
226 $this->string = $string;
227 if (empty($idSites)) {
228 $idSites = [];
229 } elseif (!is_array($idSites)) {
230 $idSites = [$idSites];
231 }
232 $this->idSites = $idSites;
233 $segment = new SegmentExpression($string);
234 $this->segmentExpression = $segment;
235 // parse segments
236 $expressions = $segment->parseSubExpressions();
237 $expressions = $this->getExpressionsWithUnionsResolved($expressions);
238 $expressions = $this->mergeSubqueryExpressionsInTree($expressions);
239 // convert segments name to sql segment
240 // check that user is allowed to view this segment
241 // and apply a filter to the value to match if necessary (to map DB fields format)
242 $cleanedExpressions = array_map(function (array $orExpressions) {
243 return array_map(function (array $operand) {
244 return $this->getCleanedExpression($operand);
245 }, $orExpressions);
246 }, $expressions);
247 $segment->setSubExpressionsAfterCleanup($cleanedExpressions);
248 }
249 private function getExpressionsWithUnionsResolved(array $expressions) : array
250 {
251 $expressionsWithUnions = array_map(function ($orExpressions) {
252 $mappedOrExpressions = [];
253 foreach ($orExpressions as $operand) {
254 $name = $operand[SegmentExpression::INDEX_OPERAND_NAME];
255 $availableSegment = $this->getSegmentByName($name);
256 // We leave segments using !@ and != operands untouched for segments not on log_visit table as they will be build using a subquery
257 if (!$this->doesSegmentNeedSubquery($operand[SegmentExpression::INDEX_OPERAND_OPERATOR], $name) && !empty($availableSegment['unionOfSegments'])) {
258 foreach ($availableSegment['unionOfSegments'] as $segmentNameOfUnion) {
259 $operand[SegmentExpression::INDEX_OPERAND_NAME] = $segmentNameOfUnion;
260 $mappedOrExpressions[] = $operand;
261 }
262 } else {
263 $mappedOrExpressions[] = $operand;
264 }
265 }
266 return $mappedOrExpressions;
267 }, $expressions);
268 return $expressionsWithUnions;
269 }
270 private function isVisitSegment($name)
271 {
272 $availableSegment = $this->getSegmentByName($name);
273 if (!empty($availableSegment['unionOfSegments'])) {
274 foreach ($availableSegment['unionOfSegments'] as $segmentNameOfUnion) {
275 $unionSegment = $this->getSegmentByName($segmentNameOfUnion);
276 if (strpos($unionSegment['sqlSegment'], 'log_visit.') === 0) {
277 return \true;
278 }
279 }
280 } elseif (strpos($availableSegment['sqlSegment'], 'log_visit.') === 0) {
281 return \true;
282 }
283 return \false;
284 }
285 private function doesSegmentNeedSubquery($operator, $segmentName)
286 {
287 $requiresSubQuery = in_array($operator, [SegmentExpression::MATCH_DOES_NOT_CONTAIN, SegmentExpression::MATCH_NOT_EQUAL]) && !$this->isVisitSegment($segmentName);
288 if ($requiresSubQuery && empty($this->startDate) && empty($this->endDate)) {
289 if (\Piwik\Development::isEnabled()) {
290 $this->missingDatesException = new Exception();
291 }
292 return \false;
293 }
294 return $requiresSubQuery;
295 }
296 private function getInvertedOperatorForSubQuery($operator)
297 {
298 if ($operator === SegmentExpression::MATCH_DOES_NOT_CONTAIN) {
299 return SegmentExpression::MATCH_CONTAINS;
300 } elseif ($operator === SegmentExpression::MATCH_NOT_EQUAL) {
301 return SegmentExpression::MATCH_EQUAL;
302 }
303 throw new Exception("Operator not support for subqueries");
304 }
305 /**
306 * Returns `true` if the segment is empty, `false` if otherwise.
307 */
308 public function isEmpty()
309 {
310 return $this->segmentExpression->isEmpty();
311 }
312 /**
313 * Detects whether the Piwik instance is configured to be able to archive this segment. It checks whether the segment
314 * will be either archived via browser or cli archiving. It does not check if the segment has been archived. If you
315 * want to know whether the segment has been archived, the actual report data needs to be requested.
316 *
317 * This method does not take any date/period into consideration. Meaning a Piwik instance might be able to archive
318 * this segment in general, but not for a certain period if eg the archiving of range dates is disabled.
319 *
320 * @return bool
321 */
322 public function willBeArchived()
323 {
324 if ($this->isEmpty()) {
325 return \true;
326 }
327 $idSites = $this->idSites;
328 return Rules::isRequestAuthorizedToArchive() || Rules::isBrowserArchivingAvailableForSegments() || Rules::isSegmentPreProcessed($idSites, $this);
329 }
330 protected function getCleanedExpression(array $expression) : array
331 {
332 $name = $expression[SegmentExpression::INDEX_OPERAND_NAME];
333 $matchType = $expression[SegmentExpression::INDEX_OPERAND_OPERATOR];
334 $value = $expression[SegmentExpression::INDEX_OPERAND_VALUE];
335 if (empty($this->idSites)) {
336 $segmentsList = SegmentsList::get();
337 } else {
338 $segmentsList = \Piwik\Context::changeIdSite(implode(',', $this->idSites), function () {
339 return SegmentsList::get();
340 });
341 }
342 $segmentObject = $segmentsList->getSegment($name);
343 $sqlName = $segmentObject ? $segmentObject->getSqlSegment() : null;
344 $joinTable = null;
345 if ($segmentObject && $segmentObject->dimension && $segmentObject->dimension->getDbColumnJoin()) {
346 $join = $segmentObject->dimension->getDbColumnJoin();
347 $dbDiscriminator = $segmentObject->dimension->getDbDiscriminator();
348 // we append alias since an archive query may add the table with a different join. we could eg add $table_$segmentName but
349 // then we would join an extra table per segment when we ideally want to join each table only once. However, we still need
350 // to see which table/column it joins to join it accurately each table extra if the same table is joined with different columns;
351 $tableAlias = $join->getTable() . '_segment_' . str_replace('.', '', $sqlName ?: '');
352 $joinConditions = [$sqlName . ' = ' . $tableAlias . '.' . $join->getColumn()];
353 // additional key columns scope the join to the same row on both tables (eg the site id),
354 // so a value cannot match a row that only shares the primary join column
355 $sourceTable = strpos((string) $sqlName, '.') !== \false ? strstr($sqlName, '.', \true) : null;
356 if ($sourceTable !== null) {
357 foreach ($join->getAdditionalKeyColumns() as $keyColumn) {
358 $joinConditions[] = $sourceTable . '.' . $keyColumn . ' = ' . $tableAlias . '.' . $keyColumn;
359 }
360 }
361 $joinTable = ['table' => $join->getTable(), 'tableAlias' => $tableAlias, 'field' => $tableAlias . '.' . $join->getTargetColumn(), 'joinOn' => implode(' AND ', $joinConditions)];
362 if ($dbDiscriminator) {
363 $joinTable['discriminator'] = $tableAlias . '.' . $dbDiscriminator->getColumn() . ' = \'' . $dbDiscriminator->getValue() . '\'';
364 }
365 }
366 if ($matchType == SegmentExpression::MATCH_IDVISIT_NOT_IN) {
367 $segmentObj = new \Piwik\Segment($value, $this->idSites, $this->startDate, $this->endDate);
368 $select = 'log_visit.idvisit';
369 $from = 'log_visit';
370 $datetimeField = 'visit_last_action_time';
371 $where = [];
372 $bind = [];
373 if (!empty($this->idSites)) {
374 $where[] = "{$from}.idsite IN (" . \Piwik\Common::getSqlStringFieldsArray($this->idSites) . ")";
375 $bind = $this->idSites;
376 }
377 if ($this->startDate instanceof \Piwik\Date) {
378 $where[] = "{$from}.{$datetimeField} >= ?";
379 $bind[] = $this->startDate->toString(\Piwik\Date::DATE_TIME_FORMAT);
380 }
381 if ($this->endDate instanceof \Piwik\Date) {
382 $where[] = "{$from}.{$datetimeField} <= ?";
383 $bind[] = $this->endDate->toString(\Piwik\Date::DATE_TIME_FORMAT);
384 }
385 $logQueryBuilder = StaticContainer::get('Piwik\\DataAccess\\LogQueryBuilder');
386 $forceGroupByBackup = $logQueryBuilder->getForcedInnerGroupBySubselect();
387 $logQueryBuilder->forceInnerGroupBySubselect(LogQueryBuilder::FORCE_INNER_GROUP_BY_NO_SUBSELECT);
388 $query = $segmentObj->getSelectQuery($select, $from, implode(' AND ', $where), $bind);
389 $logQueryBuilder->forceInnerGroupBySubselect($forceGroupByBackup);
390 return ['log_visit.idvisit', SegmentExpression::MATCH_ACTIONS_NOT_CONTAINS, $query, null, null];
391 }
392 if (empty($segmentObject)) {
393 throw new Exception("Segment '{$name}' is not a supported segment.");
394 }
395 $segment = $this->getSegmentByName($name);
396 if ($matchType != SegmentExpression::MATCH_IS_NOT_NULL_NOR_EMPTY && $matchType != SegmentExpression::MATCH_IS_NULL_OR_EMPTY) {
397 if (isset($segment['sqlFilterValue'])) {
398 $value = call_user_func($segment['sqlFilterValue'], $value, $segment['sqlSegment']);
399 }
400 // apply presentation filter
401 if (isset($segment['sqlFilter'])) {
402 $value = call_user_func($segment['sqlFilter'], $value, $segment['sqlSegment'], $matchType, $name);
403 if (is_null($value)) {
404 // null is returned in TableLogAction::getIdActionFromSegment()
405 return array(null, $matchType, null, null, $segment);
406 }
407 // sqlFilter-callbacks might return arrays for more complex cases
408 // e.g. see TableLogAction::getIdActionFromSegment()
409 if (is_array($value) && isset($value['SQL'])) {
410 // Special case: returned value is a sub sql expression!
411 $matchType = SegmentExpression::MATCH_ACTIONS_CONTAINS;
412 $joinTable = null;
413 }
414 if (is_array($value) && isset($value['value'])) {
415 $value = $value['value'];
416 $joinTable = !empty($value['joinTable']);
417 }
418 }
419 }
420 return array($sqlName, $matchType, $value, $joinTable, $segment);
421 }
422 /**
423 * Returns the segment condition.
424 *
425 * @return string
426 */
427 public function getString()
428 {
429 return $this->string;
430 }
431 /**
432 * Returns a hash of the segment condition, or the empty string if the segment
433 * condition is empty.
434 *
435 * @return string
436 */
437 public function getHash()
438 {
439 if (empty($this->string)) {
440 return '';
441 }
442 return self::getSegmentHash($this->string);
443 }
444 public static function getSegmentHash($definition)
445 {
446 $cache = \Piwik\Cache::getEagerCache();
447 $cacheKey = self::CACHE_KEY . md5($definition);
448 if ($cache->contains($cacheKey)) {
449 return $cache->fetch($cacheKey);
450 }
451 $defaultHash = md5(urldecode($definition));
452 // if the cache for segments already built, but this segment was not found,
453 // we return the default segment, this can be a segment from url or
454 // something like "visitorType==new"
455 if ($cache->contains(self::SEGMENT_HAS_BUILT_CACHE_KEY)) {
456 return $defaultHash;
457 }
458 // the segment hash is not built yet, let's do it
459 $model = new SegmentEditorModel();
460 $segments = $model->getAllSegmentsAndIgnoreVisibility();
461 foreach ($segments as $segment) {
462 $cacheKeyTemp = self::CACHE_KEY . md5($segment['definition']);
463 $cache->save($cacheKeyTemp, $segment['hash']);
464 $cacheKeyTemp = self::CACHE_KEY . md5(urldecode($segment['definition']));
465 $cache->save($cacheKeyTemp, $segment['hash']);
466 $cacheKeyTemp = self::CACHE_KEY . md5(urlencode($segment['definition']));
467 $cache->save($cacheKeyTemp, $segment['hash']);
468 }
469 $cache->save(self::SEGMENT_HAS_BUILT_CACHE_KEY, \true);
470 // if we found the segment, return it's hash, but maybe this
471 // segment is not stored in the db, return the default
472 if ($cache->contains($cacheKey)) {
473 return $cache->fetch($cacheKey);
474 }
475 return $defaultHash;
476 }
477 /**
478 * Extend an SQL query that aggregates data over one of the 'log_' tables with segment expressions.
479 *
480 * @param string $select The select clause. Should NOT include the **SELECT** just the columns, eg,
481 * `'t1.col1 as col1, t2.col2 as col2'`.
482 * @param array|string $from Array of table names (without prefix), eg, `array('log_visit', 'log_conversion')`.
483 * @param false|string $where (optional) Where clause, eg, `'t1.col1 = ? AND t2.col2 = ?'`.
484 * @param array|string $bind (optional) Bind parameters, eg, `array($col1Value, $col2Value)`.
485 * @param false|string $orderBy (optional) Order by clause, eg, `"t1.col1 ASC"`.
486 * @param false|string $groupBy (optional) Group by clause, eg, `"t2.col2"`.
487 * @param int $limit Limit number of result to $limit
488 * @param int $offset Specified the offset of the first row to return
489 * @param bool $forceGroupBy Force the group by and not using a subquery. Note: This may make the query slower see https://github.com/matomo-org/matomo/issues/9200#issuecomment-183641293
490 * A $groupBy value needs to be set for this to work.
491 * @return array{sql: string, bind: array<scalar>} The entire select query.
492 */
493 public function getSelectQuery($select, $from, $where = \false, $bind = array(), $orderBy = \false, $groupBy = \false, $limit = 0, $offset = 0, $forceGroupBy = \false, bool $withRollup = \false)
494 {
495 if (\Piwik\Development::isEnabled() && !empty($this->missingDatesException)) {
496 $e = new Exception();
497 \Piwik\Log::warning('Avoiding segment subquery due to missing start date and/or an end date. ' . 'Please ensure a start date and/or end date is set when initializing segment: ' . "\n\nCreation stacktrace:\n" . $this->missingDatesException->getTraceAsString() . "\n\nUsage stacktrace:\n" . $e->getTraceAsString());
498 }
499 $segmentExpression = $this->segmentExpression;
500 $limitAndOffset = null;
501 if ($limit > 0) {
502 $limitAndOffset = (int) $offset . ', ' . (int) $limit;
503 }
504 try {
505 if ($forceGroupBy && $groupBy) {
506 $this->segmentQueryBuilder->forceInnerGroupBySubselect(LogQueryBuilder::FORCE_INNER_GROUP_BY_NO_SUBSELECT);
507 }
508 $result = $this->segmentQueryBuilder->getSelectQueryString($segmentExpression, $select, $from, $where, $bind, $groupBy, $orderBy, $limitAndOffset, $withRollup);
509 } catch (Exception $e) {
510 if ($forceGroupBy && $groupBy) {
511 $this->segmentQueryBuilder->forceInnerGroupBySubselect('');
512 }
513 throw $e;
514 }
515 if ($forceGroupBy && $groupBy) {
516 $this->segmentQueryBuilder->forceInnerGroupBySubselect('');
517 }
518 return $result;
519 }
520 /**
521 * Returns the segment string.
522 *
523 * @return string
524 */
525 public function __toString()
526 {
527 return (string) $this->getString();
528 }
529 /**
530 * Combines this segment with another segment condition, if the segment condition is not already
531 * in the segment.
532 *
533 * The combination is naive in that it does not take order of operations into account.
534 *
535 * @param string $segment
536 * @param string $operator The operator to use. Should be either SegmentExpression::AND_DELIMITER
537 * or SegmentExpression::OR_DELIMITER.
538 * @param string $segmentCondition The segment condition to add.
539 * @return string
540 * @throws Exception
541 */
542 public static function combine($segment, $operator, $segmentCondition)
543 {
544 if (empty($segment)) {
545 return $segmentCondition;
546 }
547 if (empty($segmentCondition) || self::containsCondition($segment, $operator, $segmentCondition)) {
548 return $segment;
549 }
550 return $segment . $operator . $segmentCondition;
551 }
552 private static function containsCondition($segment, $operator, $segmentCondition)
553 {
554 // check when segment/condition are of same encoding
555 return strpos($segment, $operator . $segmentCondition) !== \false || strpos($segment, $segmentCondition . $operator) !== \false || strpos($segment, urlencode($operator . $segmentCondition)) !== \false || strpos($segment, urlencode($segmentCondition . $operator)) !== \false || strpos($segment, $operator . urlencode($segmentCondition)) !== \false || strpos($segment, urlencode($segmentCondition) . $operator) !== \false || strpos($segment, $operator . urldecode($segmentCondition)) !== \false || strpos($segment, urldecode($segmentCondition) . $operator) !== \false || $segment === $segmentCondition || $segment === urlencode($segmentCondition) || $segment === urldecode($segmentCondition);
556 }
557 public function getStoredSegmentName($idSite)
558 {
559 $segment = $this->getString();
560 if (empty($segment)) {
561 return \Piwik\Piwik::translate('SegmentEditor_DefaultAllVisits');
562 }
563 $availableSegments = SegmentEditor::getAllSegmentsForSite($idSite);
564 $foundStoredSegment = null;
565 foreach ($availableSegments as $storedSegment) {
566 if ($storedSegment['definition'] == $segment || $storedSegment['definition'] == urldecode($segment) || $storedSegment['definition'] == urlencode($segment) || $storedSegment['definition'] == $this->originalString || $storedSegment['definition'] == urldecode($this->originalString) || $storedSegment['definition'] == urlencode($this->originalString)) {
567 $foundStoredSegment = $storedSegment;
568 }
569 }
570 if (isset($foundStoredSegment)) {
571 // segment name is stored sanitized
572 return \Piwik\Common::unsanitizeInputValues($foundStoredSegment['name']);
573 }
574 return $this->isSegmentEncoded ? urldecode($segment) : $segment;
575 }
576 public function getOriginalString()
577 {
578 return $this->originalString;
579 }
580 /**
581 * Build subqueries for segments that are not on log_visit table but use !@ or != as operator
582 * This is required to ensure segments like actionUrl!@value really do not include any visit having an action containing `value`
583 *
584 * Adjacent segment conditions that both require subqueries are merged here into single NOT IN sql subqueries,
585 * which improves performance.
586 *
587 * Subquery segment conditions that are next to each other in a chain of OR's are merged together and
588 * subquery segment conditions that are next to each other in a chain of AND's, but are also alone and not
589 * a part of an OR expression, are merged.
590 *
591 * The operands for the merged conditions in the parsed intermediate structure use the special MATCH_IDVISIT_NOT_IN
592 * operator.
593 */
594 private function mergeSubqueryExpressionsInTree(array $tree) : array
595 {
596 $andExpressions = array_map(function ($orExpressions) {
597 return $this->mergeSubqueryExpressionsInExpr($orExpressions, \false);
598 }, $tree);
599 $mappedAndExpressions = $this->mergeSubqueryExpressionsInExpr($andExpressions, \true);
600 return $mappedAndExpressions;
601 }
602 private function mergeSubqueryExpressionsInExpr(array $expressions, bool $isAndChain) : array
603 {
604 // nothing to merge if there's only one expression
605 if (!$isAndChain && count($expressions) <= 1) {
606 return $expressions;
607 }
608 $mappedExpressions = [];
609 $idvisitNotInExpressions = [];
610 foreach ($expressions as $childExpressionsOrOperand) {
611 // if this is an AND chain w/ more than one sub-expression being OR-ed together, we can't do anything about the NOT IN subqueries there
612 if ($isAndChain && count($childExpressionsOrOperand) > 1) {
613 $mappedExpressions[] = $childExpressionsOrOperand;
614 continue;
615 }
616 $operand = $isAndChain ? $childExpressionsOrOperand[0] : $childExpressionsOrOperand;
617 $name = $operand[SegmentExpression::INDEX_OPERAND_NAME];
618 $matchType = $operand[SegmentExpression::INDEX_OPERAND_OPERATOR];
619 $value = $operand[SegmentExpression::INDEX_OPERAND_VALUE];
620 if (!$this->doesSegmentNeedSubquery($matchType, $name)) {
621 $mappedExpressions[] = $childExpressionsOrOperand;
622 continue;
623 }
624 // if the segment is pageTitle!=def, then NOT IN sql will have to be idvisit NOT IN (... WHERE pageTitle == def),
625 // so we must invert the operator before we create a MATCH_IDVISIT_NOT_IN operand below
626 $operator = $this->getInvertedOperatorForSubQuery($matchType);
627 $idvisitNotInExpressions[] = $name . $operator . $this->escapeSegmentValue($value);
628 }
629 if (!empty($idvisitNotInExpressions)) {
630 $newOperand = [SegmentExpression::INDEX_OPERAND_NAME => null, SegmentExpression::INDEX_OPERAND_OPERATOR => SegmentExpression::MATCH_IDVISIT_NOT_IN, SegmentExpression::INDEX_OPERAND_VALUE => implode($isAndChain ? SegmentExpression::OR_DELIMITER : SegmentExpression::AND_DELIMITER, $idvisitNotInExpressions)];
631 $mappedExpressions[] = $isAndChain ? [$newOperand] : $newOperand;
632 }
633 return $mappedExpressions;
634 }
635 /**
636 * Escapes segment expression delimiters in a segment value with a backslash if not already done.
637 */
638 private function escapeSegmentValue(string $value) : string
639 {
640 $delimiterPattern = SegmentExpression::AND_DELIMITER . SegmentExpression::OR_DELIMITER;
641 $pattern = '/((?<!\\\\)[' . preg_quote($delimiterPattern) . '])/';
642 return preg_replace($pattern, '\\\\$1', $value);
643 }
644 }
645