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