PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.2.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.2.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 1 year ago SegmentsList.php 1 year ago
SegmentExpression.php
426 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\Segment;
10
11 use Exception;
12 /**
13 *
14 */
15 class SegmentExpression
16 {
17 public const AND_DELIMITER = ';';
18 public const OR_DELIMITER = ',';
19 public const MATCH_EQUAL = '==';
20 public const MATCH_NOT_EQUAL = '!=';
21 public const MATCH_GREATER_OR_EQUAL = '>=';
22 public const MATCH_LESS_OR_EQUAL = '<=';
23 public const MATCH_GREATER = '>';
24 public const MATCH_LESS = '<';
25 public const MATCH_CONTAINS = '=@';
26 public const MATCH_DOES_NOT_CONTAIN = '!@';
27 public const MATCH_STARTS_WITH = '=^';
28 public const MATCH_ENDS_WITH = '=$';
29 public const BOOL_OPERATOR_OR = 'OR';
30 public const BOOL_OPERATOR_AND = 'AND';
31 public const BOOL_OPERATOR_END = '';
32 // Note: you can't write this in the API, but access this feature
33 // via field!= <- IS NOT NULL
34 // or via field== <- IS NULL / empty
35 public const MATCH_IS_NOT_NULL_NOR_EMPTY = '::NOT_NULL';
36 public const MATCH_IS_NULL_OR_EMPTY = '::NULL';
37 // Special case, since we look up Page URLs/Page titles in a sub SQL query
38 public const MATCH_ACTIONS_CONTAINS = 'IN';
39 public const MATCH_ACTIONS_NOT_CONTAINS = 'NOTIN';
40 /**
41 * A special match type for segments that require rejecting a visit if any action/conversion/etc. in the visit matches a condition.
42 * These operands result in `idvisit NOT IN (...)` subqueries.
43 */
44 public const MATCH_IDVISIT_NOT_IN = 'IDVISIT_NOTIN';
45 public const INDEX_OPERAND_NAME = 0;
46 public const INDEX_OPERAND_OPERATOR = 1;
47 public const INDEX_OPERAND_VALUE = 2;
48 public const INDEX_OPERAND_JOIN_COLUMN = 3;
49 public const INDEX_OPERAND_SEGMENT_INFO = 4;
50 public const SQL_WHERE_DO_NOT_MATCH_ANY_ROW = "(1 = 0)";
51 public const SQL_WHERE_MATCHES_ALL_ROWS = "(1 = 1)";
52 protected $string;
53 protected $valuesBind = [];
54 protected $tree = [];
55 protected $parsedSubExpressions = [];
56 public function __construct($string)
57 {
58 $this->string = $string;
59 $this->tree = $this->parseTree();
60 }
61 public function getSegmentDefinition()
62 {
63 return $this->string;
64 }
65 public function isEmpty()
66 {
67 return count($this->tree) == 0;
68 }
69 public function getSubExpressionCount()
70 {
71 $count = 0;
72 foreach ($this->parsedSubExpressions as $orExpressions) {
73 foreach ($orExpressions as $operand) {
74 $isExpressionColumnPresent = !empty($operand[self::INDEX_OPERAND_NAME]);
75 if ($isExpressionColumnPresent) {
76 ++$count;
77 }
78 }
79 }
80 return $count;
81 }
82 /**
83 * Given the array of parsed filters containing, for each filter,
84 * the boolean operator (AND/OR) and the operand,
85 * Will return the array where the filters are in SQL representation
86 *
87 * @throws Exception
88 * @return array
89 */
90 public function parseSubExpressions()
91 {
92 $parsedSubExpressions = array_map(function (array $orExpressions) {
93 return array_map(function (string $operand) {
94 return $this->parseOperand($operand);
95 }, $orExpressions);
96 }, $this->tree);
97 $this->parsedSubExpressions = $parsedSubExpressions;
98 return $parsedSubExpressions;
99 }
100 private function parseOperand(string $operand) : array
101 {
102 $operand = urldecode($operand);
103 $pattern = '/^(.+?)(' . self::MATCH_EQUAL . '|' . self::MATCH_NOT_EQUAL . '|' . self::MATCH_GREATER_OR_EQUAL . '|' . self::MATCH_GREATER . '|' . self::MATCH_LESS_OR_EQUAL . '|' . self::MATCH_LESS . '|' . self::MATCH_CONTAINS . '|' . self::MATCH_DOES_NOT_CONTAIN . '|' . preg_quote(self::MATCH_STARTS_WITH) . '|' . preg_quote(self::MATCH_ENDS_WITH) . '){1}(.*)/';
104 $match = preg_match($pattern, $operand, $matches);
105 if ($match == 0) {
106 throw new Exception('The segment condition \'' . $operand . '\' is not valid.');
107 }
108 $leftMember = $matches[1];
109 $operation = $matches[2];
110 $valueRightMember = urldecode($matches[3]);
111 // is null / is not null
112 if ($valueRightMember === '') {
113 if ($operation == self::MATCH_NOT_EQUAL) {
114 $operation = self::MATCH_IS_NOT_NULL_NOR_EMPTY;
115 } elseif ($operation == self::MATCH_EQUAL) {
116 $operation = self::MATCH_IS_NULL_OR_EMPTY;
117 } else {
118 throw new Exception('The segment \'' . $operand . '\' has no value specified. You can leave this value empty ' . 'only when you use the operators: ' . self::MATCH_NOT_EQUAL . ' (is not) or ' . self::MATCH_EQUAL . ' (is)');
119 }
120 }
121 return [self::INDEX_OPERAND_NAME => $leftMember, self::INDEX_OPERAND_OPERATOR => $operation, self::INDEX_OPERAND_VALUE => $valueRightMember];
122 }
123 /**
124 * Set the given expression
125 * @param $parsedSubExpressions
126 */
127 public function setSubExpressionsAfterCleanup($parsedSubExpressions)
128 {
129 $this->parsedSubExpressions = $parsedSubExpressions;
130 }
131 /**
132 * @param array $availableTables
133 */
134 public function parseSubExpressionsIntoSqlExpressions(&$availableTables = array())
135 {
136 $this->valuesBind = [];
137 $sqlSubExpressions = array_map(function (array $orExpressions) use(&$availableTables) {
138 return array_map(function (array $operandDefinition) use(&$availableTables) {
139 $operand = $this->getSqlMatchFromDefinition($operandDefinition, $availableTables);
140 if ($operand[self::INDEX_OPERAND_OPERATOR] !== null) {
141 if (is_array($operand[self::INDEX_OPERAND_OPERATOR])) {
142 $this->valuesBind = array_merge($this->valuesBind, $operand[self::INDEX_OPERAND_OPERATOR]);
143 } else {
144 $this->valuesBind[] = $operand[self::INDEX_OPERAND_OPERATOR];
145 }
146 }
147 $operand = $operand[self::INDEX_OPERAND_NAME];
148 return $operand;
149 }, $orExpressions);
150 }, $this->parsedSubExpressions);
151 $this->tree = $sqlSubExpressions;
152 }
153 /**
154 * Given an array representing one filter operand ( left member , operation , right member)
155 * Will return an array containing
156 * - the SQL substring,
157 * - the values to bind to this substring
158 *
159 * @param array $def
160 * @param array $availableTables
161 * @throws Exception
162 * @return array
163 */
164 protected function getSqlMatchFromDefinition($def, &$availableTables)
165 {
166 $field = $def[self::INDEX_OPERAND_NAME];
167 $matchType = $def[self::INDEX_OPERAND_OPERATOR];
168 $value = $def[self::INDEX_OPERAND_VALUE];
169 $join = $def[self::INDEX_OPERAND_JOIN_COLUMN] ?? null;
170 $segment = $def[self::INDEX_OPERAND_SEGMENT_INFO] ?? null;
171 // Note: we save the SQL used in the subquery (if there is one), as we don't want to run
172 // self::checkFieldIsAvailable() on it.
173 $innerSql = null;
174 if (empty($sqlExpression)) {
175 // Segment::getCleanedExpression() may return array(null, $matchType, null)
176 $operandWillNotMatchAnyRow = empty($field) && is_null($value);
177 if ($operandWillNotMatchAnyRow) {
178 if ($matchType == self::MATCH_EQUAL) {
179 // eg. pageUrl==DoesNotExist
180 // Equal to NULL means it will match none
181 $sqlExpression = self::SQL_WHERE_DO_NOT_MATCH_ANY_ROW;
182 } elseif ($matchType == self::MATCH_NOT_EQUAL) {
183 // eg. pageUrl!=DoesNotExist
184 // Not equal to NULL means it matches all rows
185 $sqlExpression = self::SQL_WHERE_MATCHES_ALL_ROWS;
186 } elseif ($matchType == self::MATCH_CONTAINS || $matchType == self::MATCH_DOES_NOT_CONTAIN || $matchType == self::MATCH_STARTS_WITH || $matchType == self::MATCH_ENDS_WITH) {
187 // no action was found for CONTAINS / DOES NOT CONTAIN
188 // eg. pageUrl=@DoesNotExist -> matches no row
189 // eg. pageUrl!@DoesNotExist -> matches no rows
190 $sqlExpression = self::SQL_WHERE_DO_NOT_MATCH_ANY_ROW;
191 } else {
192 // it is not expected to reach this code path
193 throw new Exception("Unexpected match type {$matchType} for your segment. " . "Please report this issue to the Matomo team with the segment you are using.");
194 }
195 return array($sqlExpression, $value = null);
196 }
197 $alsoMatchNULLValues = \false;
198 switch ($matchType) {
199 case self::MATCH_EQUAL:
200 $sqlMatch = '%s =';
201 break;
202 case self::MATCH_NOT_EQUAL:
203 $sqlMatch = '%s <>';
204 $alsoMatchNULLValues = \true;
205 break;
206 case self::MATCH_GREATER:
207 $sqlMatch = '%s >';
208 break;
209 case self::MATCH_LESS:
210 $sqlMatch = '%s <';
211 break;
212 case self::MATCH_GREATER_OR_EQUAL:
213 $sqlMatch = '%s >=';
214 break;
215 case self::MATCH_LESS_OR_EQUAL:
216 $sqlMatch = '%s <=';
217 break;
218 case self::MATCH_CONTAINS:
219 $sqlMatch = '%s LIKE';
220 $value = '%' . $this->escapeLikeString($value) . '%';
221 break;
222 case self::MATCH_DOES_NOT_CONTAIN:
223 $sqlMatch = '%s NOT LIKE';
224 $value = '%' . $this->escapeLikeString($value) . '%';
225 $alsoMatchNULLValues = \true;
226 break;
227 case self::MATCH_STARTS_WITH:
228 $sqlMatch = '%s LIKE';
229 $value = $this->escapeLikeString($value) . '%';
230 break;
231 case self::MATCH_ENDS_WITH:
232 $sqlMatch = '%s LIKE';
233 $value = '%' . $this->escapeLikeString($value);
234 break;
235 case self::MATCH_IS_NOT_NULL_NOR_EMPTY:
236 $sqlMatch = '%s IS NOT NULL AND %s <> \'\' AND %s <> \'0\'';
237 $value = null;
238 break;
239 case self::MATCH_IS_NULL_OR_EMPTY:
240 $sqlMatch = '%s IS NULL OR %s = \'\' OR %s = \'0\'';
241 $value = null;
242 break;
243 case self::MATCH_ACTIONS_CONTAINS:
244 // this match type is not accessible from the outside
245 // (it won't be matched in self::parseSubExpressions())
246 // it can be used internally to inject sub-expressions into the query.
247 // see Segment::getCleanedExpression()
248 $innerSql = $value['SQL'];
249 $sqlMatch = '%s IN (%innerSql%)';
250 $value = $value['bind'];
251 break;
252 case self::MATCH_ACTIONS_NOT_CONTAINS:
253 // this match type is not accessible from the outside
254 // (it won't be matched in self::parseSubExpressions())
255 // it can be used internally to inject sub-expressions into the query.
256 // see Segment::getCleanedExpression()
257 $innerSql = $value['sql'];
258 $sqlMatch = '%s NOT IN (%innerSql%)';
259 $value = $value['bind'];
260 break;
261 default:
262 throw new Exception("Filter contains the match type '" . $matchType . "' which is not supported");
263 break;
264 }
265 // We match NULL values when rows are excluded only when we are not doing a
266 $alsoMatchNULLValues = $alsoMatchNULLValues && !empty($value);
267 if ($matchType === self::MATCH_ACTIONS_CONTAINS || $matchType === self::MATCH_ACTIONS_NOT_CONTAINS || is_null($value)) {
268 $sqlExpression = "( {$sqlMatch} )";
269 } else {
270 if ($alsoMatchNULLValues) {
271 $sqlExpression = "( {$field} IS NULL OR {$sqlMatch} ? )";
272 } else {
273 $sqlExpression = "{$sqlMatch} ?";
274 }
275 }
276 }
277 if (!empty($join['field'])) {
278 $sqlExpression = str_replace('%s', $join['field'], $sqlExpression);
279 } else {
280 $sqlExpression = str_replace('%s', $field, $sqlExpression);
281 }
282 if (!empty($join['discriminator'])) {
283 $sqlExpression = '(' . $sqlExpression . ' AND ' . $join['discriminator'] . ')';
284 }
285 $columns = self::parseColumnsFromSqlExpr($sqlExpression);
286 foreach ($columns as $column) {
287 $this->checkFieldIsAvailable($column, $availableTables, $join);
288 }
289 if (!empty($join['field'])) {
290 $this->checkFieldIsAvailable($join['field'], $availableTables, $join);
291 }
292 if (!empty($join['joinOn'])) {
293 $joinOnColumns = self::parseColumnsFromSqlExpr($join['joinOn']);
294 foreach ($joinOnColumns as $column) {
295 $this->checkFieldIsAvailable($column, $availableTables, $join);
296 }
297 }
298 if ($innerSql) {
299 $sqlExpression = str_replace('%innerSql%', $innerSql, $sqlExpression);
300 }
301 return array($sqlExpression, $value);
302 }
303 /**
304 * @param string $field
305 * @return string[]
306 */
307 public static function parseColumnsFromSqlExpr($field)
308 {
309 preg_match_all('/[^@a-zA-Z0-9_]?`?([@a-zA-Z_][@a-zA-Z0-9_]*`?\\.`?[a-zA-Z0-9_`]+)`?\\b/', $field, $matches);
310 $result = isset($matches[1]) ? $matches[1] : [];
311 // remove uses of session vars
312 $result = array_filter($result, function ($value) {
313 return strpos($value, '@') === \false;
314 });
315 $result = array_map(function ($item) {
316 return str_replace('`', '', $item);
317 }, $result);
318 $result = array_unique($result);
319 $result = array_values($result);
320 return $result;
321 }
322 /**
323 * Check whether the field is available
324 * If not, add it to the available tables
325 *
326 * @param string $field
327 * @param array $availableTables
328 */
329 private function checkFieldIsAvailable($field, &$availableTables, $join)
330 {
331 $fieldParts = explode('.', $field);
332 $table = count($fieldParts) == 2 ? $fieldParts[0] : \false;
333 // remove sql functions from field name
334 // example: `HOUR(log_visit.visit_last_action_time)` gets `HOUR(log_visit` => remove `HOUR(`
335 $table = preg_replace('/^[A-Z_]+\\(/', '', $table);
336 $tableExists = !$table || in_array($table, $availableTables);
337 if ($tableExists) {
338 return;
339 }
340 if (is_array($availableTables)) {
341 foreach ($availableTables as $availableTable) {
342 if (is_array($availableTable)) {
343 if (!isset($availableTable['tableAlias']) && $availableTable['table'] === $table) {
344 return;
345 } elseif (isset($availableTable['tableAlias']) && $availableTable['tableAlias'] === $table) {
346 return;
347 }
348 }
349 }
350 }
351 if ($join && (empty($join['tableAlias']) && $table == $join['table'] || $table == $join['tableAlias'])) {
352 $availableTables[] = $join;
353 } else {
354 $availableTables[] = $table;
355 }
356 }
357 /**
358 * Escape the characters % and _ in the given string
359 * @param string $str
360 * @return string
361 */
362 private function escapeLikeString($str)
363 {
364 if (\false !== strpos($str, '%')) {
365 $str = str_replace("%", "\\%", $str);
366 }
367 if (\false !== strpos($str, '_')) {
368 $str = str_replace("_", "\\_", $str);
369 }
370 return $str;
371 }
372 /**
373 * Given a segment string, will parse it into a multi-level array with the first level representing AND groups
374 * and the second level containing OR operands
375 *
376 * eg. the segment string 'A,B;C,D,E;F' will return:
377 *
378 * [
379 * 0 => [0 => 'A', 1 => 'B'], // First AND group containing A and B OR conditions
380 * 1 => [0 => 'C', 1 => 'D', 2 => 'E', // Second AND group containing C, D & E
381 * 2 => [0 => 'F'] // Third AND group containing just F
382 * ]
383 *
384 * @return array
385 */
386 protected function parseTree()
387 {
388 $segmentStr = trim($this->string);
389 if (empty($segmentStr)) {
390 return [];
391 }
392 // split on ; only when there's no end of string after and only when there's no backslash before it
393 $ands = array_filter(preg_split('/' . self::AND_DELIMITER . '(?!$)(?<!\\\\' . self::AND_DELIMITER . ')/', $segmentStr));
394 return array_map(function ($and) {
395 // split on , only when there's no end of string after and only when there's no backslash before it
396 $ors = preg_split('/' . self::OR_DELIMITER . '(?!$)(?<!\\\\' . self::OR_DELIMITER . ')/', $and);
397 // remove backslash from in front of ; and ,
398 return array_map(function ($or) {
399 return str_replace(['\\' . self::AND_DELIMITER, '\\' . self::OR_DELIMITER], [self::AND_DELIMITER, self::OR_DELIMITER], $or);
400 }, $ors);
401 }, $ands);
402 }
403 /**
404 * Given the array of parsed boolean logic, will return
405 * an array containing the full SQL string representing the filter and the values to bind to the query
406 *
407 * @throws Exception
408 * @return array SQL Query and Bind parameters
409 */
410 public function getSql()
411 {
412 if ($this->isEmpty()) {
413 throw new Exception("Invalid segment, please specify a valid segment.");
414 }
415 $andExpressions = $this->tree;
416 $andExpressions = array_map(function ($orExpressions) {
417 if (count($orExpressions) == 1) {
418 return $orExpressions[0];
419 }
420 return '( ' . implode(' OR ', $orExpressions) . ')';
421 }, $andExpressions);
422 $sql = implode(' AND ', $andExpressions);
423 return array('where' => $sql, 'bind' => $this->valuesBind);
424 }
425 }
426