LogQueryBuilder
3 months ago
Actions.php
6 months ago
ArchiveSelector.php
1 month ago
ArchiveTableCreator.php
1 month ago
ArchiveTableDao.php
1 month ago
ArchiveWriter.php
1 month ago
ArchivingDbAdapter.php
1 year ago
LogAggregator.php
1 month ago
LogQueryBuilder.php
6 months ago
LogTableTemporary.php
2 years ago
Model.php
1 month ago
RawLogDao.php
6 months ago
TableMetadata.php
1 year ago
LogQueryBuilder.php
262 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\DataAccess; |
| 10 | |
| 11 | use Exception; |
| 12 | use Piwik\DataAccess\LogQueryBuilder\JoinGenerator; |
| 13 | use Piwik\DataAccess\LogQueryBuilder\JoinTables; |
| 14 | use Piwik\Plugin\LogTablesProvider; |
| 15 | use Piwik\Segment\SegmentExpression; |
| 16 | class LogQueryBuilder |
| 17 | { |
| 18 | public const FORCE_INNER_GROUP_BY_NO_SUBSELECT = '__##nosubquery##__'; |
| 19 | /** |
| 20 | * @var LogTablesProvider |
| 21 | */ |
| 22 | private $logTableProvider; |
| 23 | /** |
| 24 | * Forces to use a subselect when generating the query. Set value to `false` to force not using a subselect. |
| 25 | * @var string |
| 26 | */ |
| 27 | private $forcedInnerGroupBy = ''; |
| 28 | public function __construct(LogTablesProvider $logTablesProvider) |
| 29 | { |
| 30 | $this->logTableProvider = $logTablesProvider; |
| 31 | } |
| 32 | /** |
| 33 | * Forces to use a subselect when generating the query. |
| 34 | * @var string |
| 35 | */ |
| 36 | public function forceInnerGroupBySubselect($innerGroupBy) |
| 37 | { |
| 38 | $this->forcedInnerGroupBy = $innerGroupBy; |
| 39 | } |
| 40 | public function getForcedInnerGroupBySubselect() |
| 41 | { |
| 42 | return $this->forcedInnerGroupBy; |
| 43 | } |
| 44 | public function getSelectQueryString(SegmentExpression $segmentExpression, $select, $from, $where, $bind, $groupBy, $orderBy, $limitAndOffset, bool $withRollup = \false) |
| 45 | { |
| 46 | if (!is_array($from)) { |
| 47 | $from = array($from); |
| 48 | } |
| 49 | $fromInitially = $from; |
| 50 | if (!$segmentExpression->isEmpty()) { |
| 51 | $segmentExpression->parseSubExpressionsIntoSqlExpressions($from); |
| 52 | $segmentSql = $segmentExpression->getSql(); |
| 53 | $where = $this->getWhereMatchBoth($where, $segmentSql['where']); |
| 54 | $bind = array_merge($bind, $segmentSql['bind']); |
| 55 | } |
| 56 | // hack to allow db planner and db query optimiser to use an anti-join which results in a lower cost query |
| 57 | // and filtering on the log_visit table first when it doesn't need to consider null-extended rows |
| 58 | if ($from === ['log_link_visit_action', 'log_visit']) { |
| 59 | $from[1] = ['table' => 'log_visit', 'join' => 'INNER JOIN']; |
| 60 | } |
| 61 | $tables = new JoinTables($this->logTableProvider, $from); |
| 62 | $join = new JoinGenerator($tables); |
| 63 | $join->generate(); |
| 64 | $from = $join->getJoinString(); |
| 65 | $joinWithSubSelect = $join->shouldJoinWithSelect(); |
| 66 | // hack for https://github.com/piwik/piwik/issues/9194#issuecomment-164321612 |
| 67 | $useSpecialConversionGroupBy = !empty($segmentSql) && strpos($groupBy, 'log_conversion.idgoal') !== \false && $fromInitially == array('log_conversion') && strpos($from, 'log_link_visit_action') !== \false; |
| 68 | if (!empty($this->forcedInnerGroupBy)) { |
| 69 | if ($this->forcedInnerGroupBy === self::FORCE_INNER_GROUP_BY_NO_SUBSELECT) { |
| 70 | $sql = $this->buildSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset); |
| 71 | } else { |
| 72 | $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables, $this->forcedInnerGroupBy); |
| 73 | } |
| 74 | } elseif ($useSpecialConversionGroupBy) { |
| 75 | $innerGroupBy = "CONCAT(log_conversion.idvisit, '_' , log_conversion.idgoal, '_', log_conversion.buster)"; |
| 76 | $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables, $innerGroupBy); |
| 77 | } elseif ($joinWithSubSelect) { |
| 78 | $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables); |
| 79 | } else { |
| 80 | $sql = $this->buildSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $withRollup); |
| 81 | } |
| 82 | return array('sql' => $sql, 'bind' => $bind); |
| 83 | } |
| 84 | private function getKnownTables() |
| 85 | { |
| 86 | $names = array(); |
| 87 | foreach ($this->logTableProvider->getAllLogTablesWithTemporary() as $logTable) { |
| 88 | $names[] = $logTable->getName(); |
| 89 | } |
| 90 | return $names; |
| 91 | } |
| 92 | /** |
| 93 | * Build a select query where actions have to be joined on visits (or conversions) |
| 94 | * In this case, the query gets wrapped in another query so that grouping by visit is possible |
| 95 | * @param string $select |
| 96 | * @param string $from |
| 97 | * @param string $where |
| 98 | * @param string $groupBy |
| 99 | * @param string $orderBy |
| 100 | * @param string $limitAndOffset |
| 101 | * @param null|string $innerGroupBy If given, this inner group by will be used. If not, we try to detect one |
| 102 | * @throws Exception |
| 103 | * @return string |
| 104 | */ |
| 105 | private function buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, JoinTables $tables, $innerGroupBy = null) |
| 106 | { |
| 107 | $matchTables = $this->getKnownTables(); |
| 108 | foreach ($tables as $table) { |
| 109 | if (is_array($table) && isset($table['tableAlias']) && !in_array($table['tableAlias'], $matchTables, $strict = \true)) { |
| 110 | $matchTables[] = $table['tableAlias']; |
| 111 | } elseif (is_array($table) && isset($table['table']) && !in_array($table['table'], $matchTables, $strict = \true)) { |
| 112 | $matchTables[] = $table['table']; |
| 113 | } elseif (is_string($table) && !in_array($table, $matchTables, $strict = \true)) { |
| 114 | $matchTables[] = $table; |
| 115 | } |
| 116 | } |
| 117 | $matchTables = '(' . implode('|', $matchTables) . ')'; |
| 118 | preg_match_all("/" . $matchTables . "\\.[a-z0-9_\\*]+/", $select, $matches); |
| 119 | $neededFields = array_unique($matches[0]); |
| 120 | if (count($neededFields) == 0) { |
| 121 | throw new Exception("No needed fields found in select expression. " . "Please use a table prefix."); |
| 122 | } |
| 123 | $fieldNames = array(); |
| 124 | $toBeReplaced = array(); |
| 125 | $epregReplace = array(); |
| 126 | foreach ($neededFields as &$neededField) { |
| 127 | $parts = explode('.', $neededField); |
| 128 | if (count($parts) === 2 && !empty($parts[1])) { |
| 129 | if (in_array($parts[1], $fieldNames, $strict = \true)) { |
| 130 | // eg when selecting 2 dimensions log_action_X.name |
| 131 | $columnAs = $parts[1] . md5($neededField); |
| 132 | $fieldNames[] = $columnAs; |
| 133 | // we make sure to not replace a idvisitor column when duplicate column is idvisit |
| 134 | $toBeReplaced[$neededField . ' '] = $parts[0] . '.' . $columnAs . ' '; |
| 135 | $toBeReplaced[$neededField . ')'] = $parts[0] . '.' . $columnAs . ')'; |
| 136 | $toBeReplaced[$neededField . '`'] = $parts[0] . '.' . $columnAs . '`'; |
| 137 | $toBeReplaced[$neededField . ','] = $parts[0] . '.' . $columnAs . ','; |
| 138 | // replace when string ends this, we need to use regex to check for this |
| 139 | $epregReplace["/(" . $neededField . ")\$/"] = $parts[0] . '.' . $columnAs; |
| 140 | $neededField .= ' as ' . $columnAs; |
| 141 | } else { |
| 142 | $fieldNames[] = $parts[1]; |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | preg_match_all("/" . $matchTables . "/", $from, $matchesFrom); |
| 147 | $innerSelect = implode(", \n", $neededFields); |
| 148 | $innerFrom = $from; |
| 149 | $innerWhere = $where; |
| 150 | $innerLimitAndOffset = $limitAndOffset; |
| 151 | $innerOrderBy = "NULL"; |
| 152 | if ($innerLimitAndOffset && $orderBy) { |
| 153 | // only When LIMITing we can apply to the inner query the same ORDER BY as the parent query |
| 154 | $innerOrderBy = $orderBy; |
| 155 | } |
| 156 | if ($innerLimitAndOffset) { |
| 157 | // When LIMITing, no need to GROUP BY (GROUPing by is done before the LIMIT which is super slow when large amount of rows is matched) |
| 158 | $innerGroupBy = \false; |
| 159 | } |
| 160 | if (!isset($innerGroupBy) && in_array('log_visit', $matchesFrom[1])) { |
| 161 | $innerGroupBy = "log_visit.idvisit"; |
| 162 | } elseif (!isset($innerGroupBy)) { |
| 163 | throw new Exception('Cannot use subselect for join as no group by rule is specified'); |
| 164 | } |
| 165 | if (!empty($toBeReplaced)) { |
| 166 | $select = preg_replace(array_keys($epregReplace), array_values($epregReplace), $select); |
| 167 | $select = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $select); |
| 168 | if (!empty($groupBy)) { |
| 169 | $groupBy = preg_replace(array_keys($epregReplace), array_values($epregReplace), $groupBy); |
| 170 | $groupBy = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $groupBy); |
| 171 | } |
| 172 | if (!empty($orderBy)) { |
| 173 | $orderBy = preg_replace(array_keys($epregReplace), array_values($epregReplace), $orderBy); |
| 174 | $orderBy = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $orderBy); |
| 175 | } |
| 176 | } |
| 177 | $innerQuery = $this->buildSelectQuery($innerSelect, $innerFrom, $innerWhere, $innerGroupBy, $innerOrderBy, $innerLimitAndOffset); |
| 178 | $select = preg_replace('/' . $matchTables . '\\./', 'log_inner.', $select); |
| 179 | $from = "\n (\n {$innerQuery}\n ) AS log_inner"; |
| 180 | $where = \false; |
| 181 | $orderBy = preg_replace('/' . $matchTables . '\\./', 'log_inner.', $orderBy); |
| 182 | $groupBy = preg_replace('/' . $matchTables . '\\./', 'log_inner.', $groupBy); |
| 183 | $outerLimitAndOffset = null; |
| 184 | $query = $this->buildSelectQuery($select, $from, $where, $groupBy, $orderBy, $outerLimitAndOffset); |
| 185 | return $query; |
| 186 | } |
| 187 | /** |
| 188 | * Build select query the normal way |
| 189 | * |
| 190 | * @param string $select fieldlist to be selected |
| 191 | * @param string $from tablelist to select from |
| 192 | * @param string $where where clause |
| 193 | * @param string $groupBy group by clause |
| 194 | * @param string $orderBy order by clause |
| 195 | * @param string|int $limitAndOffset limit by clause eg '5' for Limit 5 Offset 0 or '10, 5' for Limit 5 Offset 10 |
| 196 | * @return string |
| 197 | */ |
| 198 | private function buildSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, bool $withRollup = \false) |
| 199 | { |
| 200 | $sql = "\n\t\t\tSELECT\n\t\t\t\t{$select}\n\t\t\tFROM\n\t\t\t\t{$from}"; |
| 201 | if ($where) { |
| 202 | $sql .= "\n\t\t\tWHERE\n\t\t\t\t{$where}"; |
| 203 | } |
| 204 | if ($groupBy) { |
| 205 | $sql .= "\n\t\t\tGROUP BY\n\t\t\t\t{$groupBy}"; |
| 206 | if ($withRollup) { |
| 207 | $sql .= "\n WITH ROLLUP"; |
| 208 | } |
| 209 | } |
| 210 | if ($orderBy) { |
| 211 | if ($withRollup) { |
| 212 | $sql = "\n SELECT * FROM (\n {$sql}\n ) AS rollupQuery"; |
| 213 | } |
| 214 | $sql .= "\n\t\t\tORDER BY\n\t\t\t\t{$orderBy}"; |
| 215 | } |
| 216 | $sql = $this->appendLimitClauseToQuery($sql, $limitAndOffset); |
| 217 | return $sql; |
| 218 | } |
| 219 | /** |
| 220 | * @param $sql |
| 221 | * @param $limit LIMIT clause eg. "10, 50" (offset 10, limit 50) |
| 222 | * @return string |
| 223 | */ |
| 224 | private function appendLimitClauseToQuery($sql, $limit) |
| 225 | { |
| 226 | $limitParts = explode(',', (string) $limit); |
| 227 | $isLimitWithOffset = 2 === count($limitParts); |
| 228 | if ($isLimitWithOffset) { |
| 229 | // $limit = "10, 5". We would not have to do this but we do to prevent possible injections. |
| 230 | $offset = trim($limitParts[0]); |
| 231 | $limit = trim($limitParts[1]); |
| 232 | $sql .= sprintf(' LIMIT %d, %d', $offset, $limit); |
| 233 | } else { |
| 234 | // $limit = "5" |
| 235 | $limit = (int) $limit; |
| 236 | if ($limit >= 1) { |
| 237 | $sql .= " LIMIT {$limit}"; |
| 238 | } |
| 239 | } |
| 240 | return $sql; |
| 241 | } |
| 242 | /** |
| 243 | * @param $where |
| 244 | * @param $segmentWhere |
| 245 | * @return string |
| 246 | * @throws |
| 247 | */ |
| 248 | protected function getWhereMatchBoth($where, $segmentWhere) |
| 249 | { |
| 250 | if (empty($segmentWhere) && empty($where)) { |
| 251 | throw new \Exception("Segment where clause should be non empty."); |
| 252 | } |
| 253 | if (empty($segmentWhere)) { |
| 254 | return $where; |
| 255 | } |
| 256 | if (empty($where)) { |
| 257 | return $segmentWhere; |
| 258 | } |
| 259 | return "( {$where} )\n AND\n ({$segmentWhere})"; |
| 260 | } |
| 261 | } |
| 262 |