PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 4.3.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v4.3.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 / SegmentExpression.php
matomo / app / core / Segment Last commit date
SegmentExpression.php 5 years ago SegmentsList.php 5 years ago
SegmentExpression.php
506 lines
1 <?php
2 /**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9
10 namespace Piwik\Segment;
11
12 use Exception;
13
14 /**
15 *
16 */
17 class SegmentExpression
18 {
19 const AND_DELIMITER = ';';
20 const OR_DELIMITER = ',';
21
22 const MATCH_EQUAL = '==';
23 const MATCH_NOT_EQUAL = '!=';
24 const MATCH_GREATER_OR_EQUAL = '>=';
25 const MATCH_LESS_OR_EQUAL = '<=';
26 const MATCH_GREATER = '>';
27 const MATCH_LESS = '<';
28 const MATCH_CONTAINS = '=@';
29 const MATCH_DOES_NOT_CONTAIN = '!@';
30 const MATCH_STARTS_WITH = '=^';
31 const MATCH_ENDS_WITH = '=$';
32
33 const BOOL_OPERATOR_OR = 'OR';
34 const BOOL_OPERATOR_AND = 'AND';
35 const BOOL_OPERATOR_END = '';
36
37 // Note: you can't write this in the API, but access this feature
38 // via field!= <- IS NOT NULL
39 // or via field== <- IS NULL / empty
40 const MATCH_IS_NOT_NULL_NOR_EMPTY = '::NOT_NULL';
41 const MATCH_IS_NULL_OR_EMPTY = '::NULL';
42
43 // Special case, since we look up Page URLs/Page titles in a sub SQL query
44 const MATCH_ACTIONS_CONTAINS = 'IN';
45 const MATCH_ACTIONS_NOT_CONTAINS = 'NOTIN';
46
47 const INDEX_BOOL_OPERATOR = 0;
48 const INDEX_OPERAND = 1;
49
50 const INDEX_OPERAND_NAME = 0;
51 const INDEX_OPERAND_OPERATOR = 1;
52 const INDEX_OPERAND_VALUE = 2;
53
54 const SQL_WHERE_DO_NOT_MATCH_ANY_ROW = "(1 = 0)";
55 const SQL_WHERE_MATCHES_ALL_ROWS = "(1 = 1)";
56
57 public function __construct($string)
58 {
59 $this->string = $string;
60 $this->tree = $this->parseTree();
61 }
62
63 public function getSegmentDefinition()
64 {
65 return $this->string;
66 }
67
68 public function isEmpty()
69 {
70 return count($this->tree) == 0;
71 }
72
73 protected $joins = array();
74 protected $valuesBind = array();
75 protected $tree = array();
76 protected $parsedSubExpressions = array();
77
78 public function getSubExpressionCount()
79 {
80 $cleaned = array_filter($this->parsedSubExpressions, function ($part) {
81 $isExpressionColumnPresent = !empty($part[1][0]);
82 return $isExpressionColumnPresent;
83 });
84 return count($cleaned);
85 }
86
87 /**
88 * Given the array of parsed filters containing, for each filter,
89 * the boolean operator (AND/OR) and the operand,
90 * Will return the array where the filters are in SQL representation
91 *
92 * @throws Exception
93 * @return array
94 */
95 public function parseSubExpressions()
96 {
97 $parsedSubExpressions = array();
98 foreach ($this->tree as $leaf) {
99 $operand = $leaf[self::INDEX_OPERAND];
100
101 $operand = urldecode($operand);
102
103 $operator = $leaf[self::INDEX_BOOL_OPERATOR];
104 $pattern = '/^(.+?)(' . self::MATCH_EQUAL . '|'
105 . self::MATCH_NOT_EQUAL . '|'
106 . self::MATCH_GREATER_OR_EQUAL . '|'
107 . self::MATCH_GREATER . '|'
108 . self::MATCH_LESS_OR_EQUAL . '|'
109 . self::MATCH_LESS . '|'
110 . self::MATCH_CONTAINS . '|'
111 . self::MATCH_DOES_NOT_CONTAIN . '|'
112 . preg_quote(self::MATCH_STARTS_WITH) . '|'
113 . preg_quote(self::MATCH_ENDS_WITH)
114 . '){1}(.*)/';
115 $match = preg_match($pattern, $operand, $matches);
116 if ($match == 0) {
117 throw new Exception('The segment condition \'' . $operand . '\' is not valid.');
118 }
119
120 $leftMember = $matches[1];
121 $operation = $matches[2];
122 $valueRightMember = urldecode($matches[3]);
123
124 // is null / is not null
125 if ($valueRightMember === '') {
126 if ($operation == self::MATCH_NOT_EQUAL) {
127 $operation = self::MATCH_IS_NOT_NULL_NOR_EMPTY;
128 } elseif ($operation == self::MATCH_EQUAL) {
129 $operation = self::MATCH_IS_NULL_OR_EMPTY;
130 } else {
131 throw new Exception('The segment \'' . $operand . '\' has no value specified. You can leave this value empty ' .
132 'only when you use the operators: ' . self::MATCH_NOT_EQUAL . ' (is not) or ' . self::MATCH_EQUAL . ' (is)');
133 }
134 }
135
136 $parsedSubExpressions[] = array(
137 self::INDEX_BOOL_OPERATOR => $operator,
138 self::INDEX_OPERAND => array(
139 self::INDEX_OPERAND_NAME => $leftMember,
140 self::INDEX_OPERAND_OPERATOR => $operation,
141 self::INDEX_OPERAND_VALUE => $valueRightMember,
142 ));
143 }
144 $this->parsedSubExpressions = $parsedSubExpressions;
145 return $parsedSubExpressions;
146 }
147
148 /**
149 * Set the given expression
150 * @param $parsedSubExpressions
151 */
152 public function setSubExpressionsAfterCleanup($parsedSubExpressions)
153 {
154 $this->parsedSubExpressions = $parsedSubExpressions;
155 }
156
157 /**
158 * @param array $availableTables
159 */
160 public function parseSubExpressionsIntoSqlExpressions(&$availableTables = array())
161 {
162 $sqlSubExpressions = array();
163 $this->valuesBind = array();
164 $this->joins = array();
165
166 foreach ($this->parsedSubExpressions as $leaf) {
167 $operator = $leaf[self::INDEX_BOOL_OPERATOR];
168 $operandDefinition = $leaf[self::INDEX_OPERAND];
169
170 $operand = $this->getSqlMatchFromDefinition($operandDefinition, $availableTables);
171
172 if ($operand[self::INDEX_OPERAND_OPERATOR] !== null) {
173 if (is_array($operand[self::INDEX_OPERAND_OPERATOR])) {
174 $this->valuesBind = array_merge($this->valuesBind, $operand[self::INDEX_OPERAND_OPERATOR]);
175 } else {
176 $this->valuesBind[] = $operand[self::INDEX_OPERAND_OPERATOR];
177 }
178 }
179
180 $operand = $operand[self::INDEX_OPERAND_NAME];
181
182 $sqlSubExpressions[] = array(
183 self::INDEX_BOOL_OPERATOR => $operator,
184 self::INDEX_OPERAND => $operand,
185 );
186 }
187
188 $this->tree = $sqlSubExpressions;
189 }
190
191 /**
192 * Given an array representing one filter operand ( left member , operation , right member)
193 * Will return an array containing
194 * - the SQL substring,
195 * - the values to bind to this substring
196 *
197 * @param array $def
198 * @param array $availableTables
199 * @throws Exception
200 * @return array
201 */
202 protected function getSqlMatchFromDefinition($def, &$availableTables)
203 {
204 $field = $def[0];
205 $matchType = $def[1];
206 $value = $def[2];
207
208 // Segment::getCleanedExpression() may return array(null, $matchType, null)
209 $operandWillNotMatchAnyRow = empty($field) && is_null($value);
210 if($operandWillNotMatchAnyRow) {
211 if($matchType == self::MATCH_EQUAL) {
212 // eg. pageUrl==DoesNotExist
213 // Equal to NULL means it will match none
214 $sqlExpression = self::SQL_WHERE_DO_NOT_MATCH_ANY_ROW;
215 } elseif($matchType == self::MATCH_NOT_EQUAL) {
216 // eg. pageUrl!=DoesNotExist
217 // Not equal to NULL means it matches all rows
218 $sqlExpression = self::SQL_WHERE_MATCHES_ALL_ROWS;
219 } elseif($matchType == self::MATCH_CONTAINS
220 || $matchType == self::MATCH_DOES_NOT_CONTAIN
221 || $matchType == self::MATCH_STARTS_WITH
222 || $matchType == self::MATCH_ENDS_WITH) {
223 // no action was found for CONTAINS / DOES NOT CONTAIN
224 // eg. pageUrl=@DoesNotExist -> matches no row
225 // eg. pageUrl!@DoesNotExist -> matches no rows
226 $sqlExpression = self::SQL_WHERE_DO_NOT_MATCH_ANY_ROW;
227 } else {
228 // it is not expected to reach this code path
229 throw new Exception("Unexpected match type $matchType for your segment. " .
230 "Please report this issue to the Matomo team with the segment you are using.");
231 }
232
233 return array($sqlExpression, $value = null);
234 }
235
236 $alsoMatchNULLValues = false;
237 switch ($matchType) {
238 case self::MATCH_EQUAL:
239 $sqlMatch = '%s =';
240 break;
241 case self::MATCH_NOT_EQUAL:
242 $sqlMatch = '%s <>';
243 $alsoMatchNULLValues = true;
244 break;
245 case self::MATCH_GREATER:
246 $sqlMatch = '%s >';
247 break;
248 case self::MATCH_LESS:
249 $sqlMatch = '%s <';
250 break;
251 case self::MATCH_GREATER_OR_EQUAL:
252 $sqlMatch = '%s >=';
253 break;
254 case self::MATCH_LESS_OR_EQUAL:
255 $sqlMatch = '%s <=';
256 break;
257 case self::MATCH_CONTAINS:
258 $sqlMatch = '%s LIKE';
259 $value = '%' . $this->escapeLikeString($value) . '%';
260 break;
261 case self::MATCH_DOES_NOT_CONTAIN:
262 $sqlMatch = '%s NOT LIKE';
263 $value = '%' . $this->escapeLikeString($value) . '%';
264 $alsoMatchNULLValues = true;
265 break;
266 case self::MATCH_STARTS_WITH:
267 $sqlMatch = '%s LIKE';
268 $value = $this->escapeLikeString($value) . '%';
269 break;
270 case self::MATCH_ENDS_WITH:
271 $sqlMatch = '%s LIKE';
272 $value = '%' . $this->escapeLikeString($value);
273 break;
274
275 case self::MATCH_IS_NOT_NULL_NOR_EMPTY:
276 $sqlMatch = '%s IS NOT NULL AND %s <> \'\' AND %s <> \'0\'';
277 $value = null;
278 break;
279
280 case self::MATCH_IS_NULL_OR_EMPTY:
281 $sqlMatch = '%s IS NULL OR %s = \'\' OR %s = \'0\'';
282 $value = null;
283 break;
284
285 case self::MATCH_ACTIONS_CONTAINS:
286 // this match type is not accessible from the outside
287 // (it won't be matched in self::parseSubExpressions())
288 // it can be used internally to inject sub-expressions into the query.
289 // see Segment::getCleanedExpression()
290 $sqlMatch = '%s IN (' . $value['SQL'] . ')';
291 $value = $value['bind'];
292 break;
293 case self::MATCH_ACTIONS_NOT_CONTAINS:
294 // this match type is not accessible from the outside
295 // (it won't be matched in self::parseSubExpressions())
296 // it can be used internally to inject sub-expressions into the query.
297 // see Segment::getCleanedExpression()
298 $sqlMatch = '%s NOT IN (' . $value['sql'] . ')';
299 $value = $value['bind'];
300 break;
301 default:
302 throw new Exception("Filter contains the match type '" . $matchType . "' which is not supported");
303 break;
304 }
305
306 // We match NULL values when rows are excluded only when we are not doing a
307 $alsoMatchNULLValues = $alsoMatchNULLValues && !empty($value);
308 $sqlMatch = str_replace('%s', $field, $sqlMatch);
309
310 if ($matchType === self::MATCH_ACTIONS_CONTAINS || $matchType === self::MATCH_ACTIONS_NOT_CONTAINS
311 || is_null($value)
312 ) {
313 $sqlExpression = "( $sqlMatch )";
314 } else {
315 if ($alsoMatchNULLValues) {
316 $sqlExpression = "( $field IS NULL OR $sqlMatch ? )";
317 } else {
318 $sqlExpression = "$sqlMatch ?";
319 }
320 }
321
322 $columns = self::parseColumnsFromSqlExpr($field);
323 foreach ($columns as $column) {
324 $this->checkFieldIsAvailable($column, $availableTables);
325 }
326
327 return array($sqlExpression, $value);
328 }
329
330 /**
331 * @param string $field
332 * @return string[]
333 */
334 public static function parseColumnsFromSqlExpr($field)
335 {
336 preg_match_all('/[^@a-zA-Z0-9_]?`?([@a-zA-Z_][@a-zA-Z0-9_]*`?\.`?[a-zA-Z0-9_`]+)`?\b/', $field, $matches);
337 $result = isset($matches[1]) ? $matches[1] : [];
338 $result = array_filter($result, function ($value) { // remove uses of session vars
339 return strpos($value, '@') === false;
340 });
341 $result = array_map(function ($item) {
342 return str_replace('`', '', $item);
343 }, $result);
344 $result = array_unique($result);
345 $result = array_values($result);
346 return $result;
347 }
348
349 /**
350 * Check whether the field is available
351 * If not, add it to the available tables
352 *
353 * @param string $field
354 * @param array $availableTables
355 */
356 private function checkFieldIsAvailable($field, &$availableTables)
357 {
358 $fieldParts = explode('.', $field);
359
360 $table = count($fieldParts) == 2 ? $fieldParts[0] : false;
361
362 // remove sql functions from field name
363 // example: `HOUR(log_visit.visit_last_action_time)` gets `HOUR(log_visit` => remove `HOUR(`
364 $table = preg_replace('/^[A-Z_]+\(/', '', $table);
365 $tableExists = !$table || in_array($table, $availableTables);
366
367 if ($tableExists) {
368 return;
369 }
370
371 if (is_array($availableTables)) {
372 foreach ($availableTables as $availableTable) {
373 if (is_array($availableTable)) {
374 if (!isset($availableTable['tableAlias']) && $availableTable['table'] === $table) {
375 return;
376 } elseif (isset($availableTable['tableAlias']) && $availableTable['tableAlias'] === $table) {
377 return;
378 }
379 }
380 }
381 }
382
383 $availableTables[] = $table;
384 }
385
386 /**
387 * Escape the characters % and _ in the given string
388 * @param string $str
389 * @return string
390 */
391 private function escapeLikeString($str)
392 {
393 if (false !== strpos($str, '%')) {
394 $str = str_replace("%", "\%", $str);
395 }
396
397 if (false !== strpos($str, '_')) {
398 $str = str_replace("_", "\_", $str);
399 }
400
401 return $str;
402 }
403
404 /**
405 * Given a filter string,
406 * will parse it into an array where each row contains the boolean operator applied to it,
407 * and the operand
408 *
409 * @return array
410 */
411 protected function parseTree()
412 {
413 $string = $this->string;
414 if (empty($string)) {
415 return array();
416 }
417 $tree = array();
418 $i = 0;
419 $length = strlen($string);
420 $isBackslash = false;
421 $operand = '';
422 while ($i <= $length) {
423 $char = $string[$i];
424
425 $isAND = ($char == self::AND_DELIMITER);
426 $isOR = ($char == self::OR_DELIMITER);
427 $isEnd = ($length == $i + 1);
428
429 if ($isEnd) {
430 if ($isBackslash && ($isAND || $isOR)) {
431 $operand = substr($operand, 0, -1);
432 }
433 $operand .= $char;
434 $tree[] = array(self::INDEX_BOOL_OPERATOR => self::BOOL_OPERATOR_END, self::INDEX_OPERAND => $operand);
435 break;
436 }
437
438 if ($isAND && !$isBackslash) {
439 $tree[] = array(self::INDEX_BOOL_OPERATOR => self::BOOL_OPERATOR_AND, self::INDEX_OPERAND => $operand);
440 $operand = '';
441 } elseif ($isOR && !$isBackslash) {
442 $tree[] = array(self::INDEX_BOOL_OPERATOR => self::BOOL_OPERATOR_OR, self::INDEX_OPERAND => $operand);
443 $operand = '';
444 } else {
445 if ($isBackslash && ($isAND || $isOR)) {
446 $operand = substr($operand, 0, -1);
447 }
448 $operand .= $char;
449 }
450 $isBackslash = ($char == "\\");
451 $i++;
452 }
453 return $tree;
454 }
455
456 /**
457 * Given the array of parsed boolean logic, will return
458 * an array containing the full SQL string representing the filter,
459 * the needed joins and the values to bind to the query
460 *
461 * @throws Exception
462 * @return array SQL Query, Joins and Bind parameters
463 */
464 public function getSql()
465 {
466 if ($this->isEmpty()) {
467 throw new Exception("Invalid segment, please specify a valid segment.");
468 }
469 $sql = '';
470 $subExpression = false;
471 foreach ($this->tree as $expression) {
472 $operator = $expression[self::INDEX_BOOL_OPERATOR];
473 $operand = $expression[self::INDEX_OPERAND];
474
475 if ($operator == self::BOOL_OPERATOR_OR
476 && !$subExpression
477 ) {
478 $sql .= ' (';
479 $subExpression = true;
480 } else {
481 $sql .= ' ';
482 }
483
484 $sql .= $operand;
485
486 if ($operator == self::BOOL_OPERATOR_AND
487 && $subExpression
488 ) {
489 $sql .= ')';
490 $subExpression = false;
491 }
492
493 $sql .= " $operator";
494 }
495 if ($subExpression) {
496 $sql .= ')';
497 }
498 return array(
499 'where' => $sql,
500 'bind' => $this->valuesBind,
501 'join' => implode(' ', $this->joins)
502 );
503 }
504 }
505
506