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