Teknik is a suite of services with attractive and functional interfaces. https://www.teknik.io/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SegmentExpression.php 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. <?php
  2. /**
  3. * Piwik - free/libre analytics platform
  4. *
  5. * @link http://piwik.org
  6. * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
  7. *
  8. */
  9. namespace Piwik;
  10. use Exception;
  11. /**
  12. *
  13. */
  14. class SegmentExpression
  15. {
  16. const AND_DELIMITER = ';';
  17. const OR_DELIMITER = ',';
  18. const MATCH_EQUAL = '==';
  19. const MATCH_NOT_EQUAL = '!=';
  20. const MATCH_GREATER_OR_EQUAL = '>=';
  21. const MATCH_LESS_OR_EQUAL = '<=';
  22. const MATCH_GREATER = '>';
  23. const MATCH_LESS = '<';
  24. const MATCH_CONTAINS = '=@';
  25. const MATCH_DOES_NOT_CONTAIN = '!@';
  26. // Note: you can't write this in the API, but access this feature
  27. // via field!= <- IS NOT NULL
  28. // or via field== <- IS NULL / empty
  29. const MATCH_IS_NOT_NULL_NOR_EMPTY = '::NOT_NULL';
  30. const MATCH_IS_NULL_OR_EMPTY = '::NULL';
  31. // Special case, since we look up Page URLs/Page titles in a sub SQL query
  32. const MATCH_ACTIONS_CONTAINS = 'IN';
  33. const INDEX_BOOL_OPERATOR = 0;
  34. const INDEX_OPERAND = 1;
  35. function __construct($string)
  36. {
  37. $this->string = $string;
  38. $this->tree = $this->parseTree();
  39. }
  40. protected $joins = array();
  41. protected $valuesBind = array();
  42. protected $parsedTree = array();
  43. protected $tree = array();
  44. protected $parsedSubExpressions = array();
  45. /**
  46. * Given the array of parsed filters containing, for each filter,
  47. * the boolean operator (AND/OR) and the operand,
  48. * Will return the array where the filters are in SQL representation
  49. *
  50. * @throws Exception
  51. * @return array
  52. */
  53. public function parseSubExpressions()
  54. {
  55. $parsedSubExpressions = array();
  56. foreach ($this->tree as $leaf) {
  57. $operand = $leaf[self::INDEX_OPERAND];
  58. $operand = urldecode($operand);
  59. $operator = $leaf[self::INDEX_BOOL_OPERATOR];
  60. $pattern = '/^(.+?)(' . self::MATCH_EQUAL . '|'
  61. . self::MATCH_NOT_EQUAL . '|'
  62. . self::MATCH_GREATER_OR_EQUAL . '|'
  63. . self::MATCH_GREATER . '|'
  64. . self::MATCH_LESS_OR_EQUAL . '|'
  65. . self::MATCH_LESS . '|'
  66. . self::MATCH_CONTAINS . '|'
  67. . self::MATCH_DOES_NOT_CONTAIN
  68. . '){1}(.*)/';
  69. $match = preg_match($pattern, $operand, $matches);
  70. if ($match == 0) {
  71. throw new Exception('The segment \'' . $operand . '\' is not valid.');
  72. }
  73. $leftMember = $matches[1];
  74. $operation = $matches[2];
  75. $valueRightMember = urldecode($matches[3]);
  76. // is null / is not null
  77. if ($valueRightMember === '') {
  78. if ($operation == self::MATCH_NOT_EQUAL) {
  79. $operation = self::MATCH_IS_NOT_NULL_NOR_EMPTY;
  80. } elseif ($operation == self::MATCH_EQUAL) {
  81. $operation = self::MATCH_IS_NULL_OR_EMPTY;
  82. } else {
  83. throw new Exception('The segment \'' . $operand . '\' has no value specified. You can leave this value empty ' .
  84. 'only when you use the operators: ' . self::MATCH_NOT_EQUAL . ' (is not) or ' . self::MATCH_EQUAL . ' (is)');
  85. }
  86. }
  87. $parsedSubExpressions[] = array(
  88. self::INDEX_BOOL_OPERATOR => $operator,
  89. self::INDEX_OPERAND => array(
  90. $leftMember,
  91. $operation,
  92. $valueRightMember,
  93. ));
  94. }
  95. $this->parsedSubExpressions = $parsedSubExpressions;
  96. return $parsedSubExpressions;
  97. }
  98. /**
  99. * Set the given expression
  100. * @param $parsedSubExpressions
  101. */
  102. public function setSubExpressionsAfterCleanup($parsedSubExpressions)
  103. {
  104. $this->parsedSubExpressions = $parsedSubExpressions;
  105. }
  106. /**
  107. * @param array $availableTables
  108. */
  109. public function parseSubExpressionsIntoSqlExpressions(&$availableTables = array())
  110. {
  111. $sqlSubExpressions = array();
  112. $this->valuesBind = array();
  113. $this->joins = array();
  114. foreach ($this->parsedSubExpressions as $leaf) {
  115. $operator = $leaf[self::INDEX_BOOL_OPERATOR];
  116. $operandDefinition = $leaf[self::INDEX_OPERAND];
  117. $operand = $this->getSqlMatchFromDefinition($operandDefinition, $availableTables);
  118. if ($operand[1] !== null) {
  119. $this->valuesBind[] = $operand[1];
  120. }
  121. $operand = $operand[0];
  122. $sqlSubExpressions[] = array(
  123. self::INDEX_BOOL_OPERATOR => $operator,
  124. self::INDEX_OPERAND => $operand,
  125. );
  126. }
  127. $this->tree = $sqlSubExpressions;
  128. }
  129. /**
  130. * Given an array representing one filter operand ( left member , operation , right member)
  131. * Will return an array containing
  132. * - the SQL substring,
  133. * - the values to bind to this substring
  134. *
  135. * @param array $def
  136. * @param array $availableTables
  137. * @throws Exception
  138. * @return array
  139. */
  140. protected function getSqlMatchFromDefinition($def, &$availableTables)
  141. {
  142. $field = $def[0];
  143. $matchType = $def[1];
  144. $value = $def[2];
  145. $alsoMatchNULLValues = false;
  146. switch ($matchType) {
  147. case self::MATCH_EQUAL:
  148. $sqlMatch = '=';
  149. break;
  150. case self::MATCH_NOT_EQUAL:
  151. $sqlMatch = '<>';
  152. $alsoMatchNULLValues = true;
  153. break;
  154. case self::MATCH_GREATER:
  155. $sqlMatch = '>';
  156. break;
  157. case self::MATCH_LESS:
  158. $sqlMatch = '<';
  159. break;
  160. case self::MATCH_GREATER_OR_EQUAL:
  161. $sqlMatch = '>=';
  162. break;
  163. case self::MATCH_LESS_OR_EQUAL:
  164. $sqlMatch = '<=';
  165. break;
  166. case self::MATCH_CONTAINS:
  167. $sqlMatch = 'LIKE';
  168. $value = '%' . $this->escapeLikeString($value) . '%';
  169. break;
  170. case self::MATCH_DOES_NOT_CONTAIN:
  171. $sqlMatch = 'NOT LIKE';
  172. $value = '%' . $this->escapeLikeString($value) . '%';
  173. $alsoMatchNULLValues = true;
  174. break;
  175. case self::MATCH_IS_NOT_NULL_NOR_EMPTY:
  176. $sqlMatch = 'IS NOT NULL AND (' . $field . ' <> \'\' OR ' . $field . ' = 0)';
  177. $value = null;
  178. break;
  179. case self::MATCH_IS_NULL_OR_EMPTY:
  180. $sqlMatch = 'IS NULL OR ' . $field . ' = \'\' ';
  181. $value = null;
  182. break;
  183. case self::MATCH_ACTIONS_CONTAINS:
  184. // this match type is not accessible from the outside
  185. // (it won't be matched in self::parseSubExpressions())
  186. // it can be used internally to inject sub-expressions into the query.
  187. // see Segment::getCleanedExpression()
  188. $sqlMatch = 'IN (' . $value['SQL'] . ')';
  189. $value = $this->escapeLikeString($value['bind']);
  190. break;
  191. default:
  192. throw new Exception("Filter contains the match type '" . $matchType . "' which is not supported");
  193. break;
  194. }
  195. // We match NULL values when rows are excluded only when we are not doing a
  196. $alsoMatchNULLValues = $alsoMatchNULLValues && !empty($value);
  197. if ($matchType === self::MATCH_ACTIONS_CONTAINS
  198. || is_null($value)
  199. ) {
  200. $sqlExpression = "( $field $sqlMatch )";
  201. } else {
  202. if ($alsoMatchNULLValues) {
  203. $sqlExpression = "( $field IS NULL OR $field $sqlMatch ? )";
  204. } else {
  205. $sqlExpression = "$field $sqlMatch ?";
  206. }
  207. }
  208. $this->checkFieldIsAvailable($field, $availableTables);
  209. return array($sqlExpression, $value);
  210. }
  211. /**
  212. * Check whether the field is available
  213. * If not, add it to the available tables
  214. *
  215. * @param string $field
  216. * @param array $availableTables
  217. */
  218. private function checkFieldIsAvailable($field, &$availableTables)
  219. {
  220. $fieldParts = explode('.', $field);
  221. $table = count($fieldParts) == 2 ? $fieldParts[0] : false;
  222. // remove sql functions from field name
  223. // example: `HOUR(log_visit.visit_last_action_time)` gets `HOUR(log_visit` => remove `HOUR(`
  224. $table = preg_replace('/^[A-Z_]+\(/', '', $table);
  225. $tableExists = !$table || in_array($table, $availableTables);
  226. if (!$tableExists) {
  227. $availableTables[] = $table;
  228. }
  229. }
  230. /**
  231. * Escape the characters % and _ in the given string
  232. * @param string $str
  233. * @return string
  234. */
  235. private function escapeLikeString($str)
  236. {
  237. $str = str_replace("%", "\%", $str);
  238. $str = str_replace("_", "\_", $str);
  239. return $str;
  240. }
  241. /**
  242. * Given a filter string,
  243. * will parse it into an array where each row contains the boolean operator applied to it,
  244. * and the operand
  245. *
  246. * @return array
  247. */
  248. protected function parseTree()
  249. {
  250. $string = $this->string;
  251. if (empty($string)) {
  252. return array();
  253. }
  254. $tree = array();
  255. $i = 0;
  256. $length = strlen($string);
  257. $isBackslash = false;
  258. $operand = '';
  259. while ($i <= $length) {
  260. $char = $string[$i];
  261. $isAND = ($char == self::AND_DELIMITER);
  262. $isOR = ($char == self::OR_DELIMITER);
  263. $isEnd = ($length == $i + 1);
  264. if ($isEnd) {
  265. if ($isBackslash && ($isAND || $isOR)) {
  266. $operand = substr($operand, 0, -1);
  267. }
  268. $operand .= $char;
  269. $tree[] = array(self::INDEX_BOOL_OPERATOR => '', self::INDEX_OPERAND => $operand);
  270. break;
  271. }
  272. if ($isAND && !$isBackslash) {
  273. $tree[] = array(self::INDEX_BOOL_OPERATOR => 'AND', self::INDEX_OPERAND => $operand);
  274. $operand = '';
  275. } elseif ($isOR && !$isBackslash) {
  276. $tree[] = array(self::INDEX_BOOL_OPERATOR => 'OR', self::INDEX_OPERAND => $operand);
  277. $operand = '';
  278. } else {
  279. if ($isBackslash && ($isAND || $isOR)) {
  280. $operand = substr($operand, 0, -1);
  281. }
  282. $operand .= $char;
  283. }
  284. $isBackslash = ($char == "\\");
  285. $i++;
  286. }
  287. return $tree;
  288. }
  289. /**
  290. * Given the array of parsed boolean logic, will return
  291. * an array containing the full SQL string representing the filter,
  292. * the needed joins and the values to bind to the query
  293. *
  294. * @throws Exception
  295. * @return array SQL Query, Joins and Bind parameters
  296. */
  297. public function getSql()
  298. {
  299. if (count($this->tree) == 0) {
  300. throw new Exception("Invalid segment, please specify a valid segment.");
  301. }
  302. $sql = '';
  303. $subExpression = false;
  304. foreach ($this->tree as $expression) {
  305. $operator = $expression[self::INDEX_BOOL_OPERATOR];
  306. $operand = $expression[self::INDEX_OPERAND];
  307. if ($operator == 'OR'
  308. && !$subExpression
  309. ) {
  310. $sql .= ' (';
  311. $subExpression = true;
  312. } else {
  313. $sql .= ' ';
  314. }
  315. $sql .= $operand;
  316. if ($operator == 'AND'
  317. && $subExpression
  318. ) {
  319. $sql .= ')';
  320. $subExpression = false;
  321. }
  322. $sql .= " $operator";
  323. }
  324. if ($subExpression) {
  325. $sql .= ')';
  326. }
  327. return array(
  328. 'where' => $sql,
  329. 'bind' => $this->valuesBind,
  330. 'join' => implode(' ', $this->joins)
  331. );
  332. }
  333. }