PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / trunk
Matomo Analytics – Powerful, Privacy-First Insights for WordPress vtrunk
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 / DataAccess / LogQueryBuilder.php
matomo / app / core / DataAccess Last commit date
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