1 | <?php |
||
2 | |||
3 | /* |
||
4 | * @copyright 2014 Mautic Contributors. All rights reserved |
||
5 | * @author Mautic |
||
6 | * |
||
7 | * @link http://mautic.org |
||
8 | * |
||
9 | * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html |
||
10 | */ |
||
11 | |||
12 | namespace Mautic\ReportBundle\Builder; |
||
13 | |||
14 | use Doctrine\DBAL\Connection; |
||
15 | use Doctrine\DBAL\Query\QueryBuilder; |
||
16 | use Mautic\ChannelBundle\Helper\ChannelListHelper; |
||
17 | use Mautic\CoreBundle\Helper\InputHelper; |
||
18 | use Mautic\ReportBundle\Entity\Report; |
||
19 | use Mautic\ReportBundle\Event\ReportGeneratorEvent; |
||
20 | use Mautic\ReportBundle\ReportEvents; |
||
21 | use Symfony\Component\EventDispatcher\EventDispatcher; |
||
22 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; |
||
23 | |||
24 | final class MauticReportBuilder implements ReportBuilderInterface |
||
25 | { |
||
26 | /** |
||
27 | * @var array |
||
28 | */ |
||
29 | const OPERATORS = [ |
||
30 | 'default' => [ |
||
31 | 'eq' => 'mautic.core.operator.equals', |
||
32 | 'gt' => 'mautic.core.operator.greaterthan', |
||
33 | 'gte' => 'mautic.core.operator.greaterthanequals', |
||
34 | 'lt' => 'mautic.core.operator.lessthan', |
||
35 | 'lte' => 'mautic.core.operator.lessthanequals', |
||
36 | 'neq' => 'mautic.core.operator.notequals', |
||
37 | 'like' => 'mautic.core.operator.islike', |
||
38 | 'notLike' => 'mautic.core.operator.isnotlike', |
||
39 | 'empty' => 'mautic.core.operator.isempty', |
||
40 | 'notEmpty' => 'mautic.core.operator.isnotempty', |
||
41 | 'contains' => 'mautic.core.operator.contains', |
||
42 | 'startsWith' => 'mautic.core.operator.starts.with', |
||
43 | 'endsWith' => 'mautic.core.operator.ends.with', |
||
44 | ], |
||
45 | 'bool' => [ |
||
46 | 'eq' => 'mautic.core.operator.equals', |
||
47 | 'neq' => 'mautic.core.operator.notequals', |
||
48 | ], |
||
49 | 'int' => [ |
||
50 | 'eq' => 'mautic.core.operator.equals', |
||
51 | 'gt' => 'mautic.core.operator.greaterthan', |
||
52 | 'gte' => 'mautic.core.operator.greaterthanequals', |
||
53 | 'lt' => 'mautic.core.operator.lessthan', |
||
54 | 'lte' => 'mautic.core.operator.lessthanequals', |
||
55 | 'neq' => 'mautic.core.operator.notequals', |
||
56 | ], |
||
57 | 'multiselect' => [ |
||
58 | 'in' => 'mautic.core.operator.in', |
||
59 | 'notIn' => 'mautic.core.operator.notin', |
||
60 | ], |
||
61 | 'select' => [ |
||
62 | 'eq' => 'mautic.core.operator.equals', |
||
63 | 'neq' => 'mautic.core.operator.notequals', |
||
64 | ], |
||
65 | 'text' => [ |
||
66 | 'eq' => 'mautic.core.operator.equals', |
||
67 | 'neq' => 'mautic.core.operator.notequals', |
||
68 | 'empty' => 'mautic.core.operator.isempty', |
||
69 | 'notEmpty' => 'mautic.core.operator.isnotempty', |
||
70 | 'like' => 'mautic.core.operator.islike', |
||
71 | 'notLike' => 'mautic.core.operator.isnotlike', |
||
72 | 'contains' => 'mautic.core.operator.contains', |
||
73 | 'startsWith' => 'mautic.core.operator.starts.with', |
||
74 | 'endsWith' => 'mautic.core.operator.ends.with', |
||
75 | ], |
||
76 | ]; |
||
77 | |||
78 | /** |
||
79 | * Standard Channel Columns. |
||
80 | */ |
||
81 | const CHANNEL_COLUMN_CATEGORY_ID = 'channel.category_id'; |
||
82 | const CHANNEL_COLUMN_NAME = 'channel.name'; |
||
83 | const CHANNEL_COLUMN_DESCRIPTION = 'channel.description'; |
||
84 | const CHANNEL_COLUMN_DATE_ADDED = 'channel.date_added'; |
||
85 | const CHANNEL_COLUMN_CREATED_BY = 'channel.created_by'; |
||
86 | const CHANNEL_COLUMN_CREATED_BY_USER = 'channel.created_by_user'; |
||
87 | |||
88 | /** |
||
89 | * @var Connection |
||
90 | */ |
||
91 | private $db; |
||
92 | |||
93 | /** |
||
94 | * @var Report |
||
95 | */ |
||
96 | private $entity; |
||
97 | |||
98 | /** |
||
99 | * @var string |
||
100 | */ |
||
101 | private $contentTemplate; |
||
102 | |||
103 | /** |
||
104 | * @var EventDispatcher |
||
105 | */ |
||
106 | private $dispatcher; |
||
107 | |||
108 | /** |
||
109 | * @var ChannelListHelper |
||
110 | */ |
||
111 | private $channelListHelper; |
||
112 | |||
113 | public function __construct(EventDispatcherInterface $dispatcher, Connection $db, Report $entity, ChannelListHelper $channelListHelper) |
||
114 | { |
||
115 | $this->entity = $entity; |
||
116 | $this->dispatcher = $dispatcher; |
||
117 | $this->db = $db; |
||
118 | $this->channelListHelper = $channelListHelper; |
||
119 | } |
||
120 | |||
121 | /** |
||
122 | * @return QueryBuilder |
||
123 | * |
||
124 | * @throws InvalidReportQueryException |
||
125 | */ |
||
126 | public function getQuery(array $options) |
||
127 | { |
||
128 | $queryBuilder = $this->configureBuilder($options); |
||
129 | |||
130 | if (QueryBuilder::SELECT !== $queryBuilder->getType()) { |
||
131 | throw new InvalidReportQueryException('Only SELECT statements are valid'); |
||
132 | } |
||
133 | |||
134 | return $queryBuilder; |
||
135 | } |
||
136 | |||
137 | /** |
||
138 | * Gets the getContentTemplate path. |
||
139 | * |
||
140 | * @return string |
||
141 | */ |
||
142 | public function getContentTemplate() |
||
143 | { |
||
144 | return $this->contentTemplate; |
||
145 | } |
||
146 | |||
147 | /** |
||
148 | * This method configures the ReportBuilder. It has to return a configured Doctrine DBAL QueryBuilder. |
||
149 | * |
||
150 | * @param array $options Options array |
||
151 | * |
||
152 | * @return QueryBuilder |
||
153 | */ |
||
154 | private function configureBuilder(array $options) |
||
155 | { |
||
156 | $event = new ReportGeneratorEvent($this->entity, $options, $this->db->createQueryBuilder(), $this->channelListHelper); |
||
157 | |||
158 | // Trigger the REPORT_ON_GENERATE event to initialize the QueryBuilder |
||
159 | $this->dispatcher->dispatch(ReportEvents::REPORT_ON_GENERATE, $event); |
||
160 | |||
161 | // Build the QUERY |
||
162 | $queryBuilder = $event->getQueryBuilder(); |
||
163 | |||
164 | // Set Content Template |
||
165 | $this->contentTemplate = $event->getContentTemplate(); |
||
166 | $standardFilters = $this->entity->getFilters(); |
||
167 | |||
168 | // Setup filters |
||
169 | if (isset($options['dynamicFilters'])) { |
||
170 | $dynamicFilters = $options['dynamicFilters']; |
||
171 | |||
172 | foreach ($dynamicFilters as $key => $dynamicFilter) { |
||
173 | foreach ($standardFilters as $i => $filter) { |
||
174 | if ($filter['column'] === $key && $filter['dynamic']) { |
||
175 | $value = $dynamicFilter['value']; |
||
176 | $condition = $filter['condition']; |
||
177 | |||
178 | switch ($condition) { |
||
179 | case 'startsWith': |
||
180 | $value = $value.'%'; |
||
181 | break; |
||
182 | case 'endsWith': |
||
183 | $value = '%'.$value; |
||
184 | break; |
||
185 | case 'like': |
||
186 | case 'notLike': |
||
187 | case 'contains': |
||
188 | if ('notLike' === $condition) { |
||
189 | $dynamicFilter['expr'] = 'notLike'; |
||
190 | } |
||
191 | |||
192 | $value = '%'.$value.'%'; |
||
193 | break; |
||
194 | } |
||
195 | |||
196 | $dynamicFilter['value'] = $value; |
||
197 | |||
198 | // Overwrite the standard filter with the dynamic |
||
199 | $standardFilters[$i] = array_merge($filter, $dynamicFilter); |
||
200 | } |
||
201 | } |
||
202 | } |
||
203 | } |
||
204 | |||
205 | // Build WHERE clause |
||
206 | if (!empty($standardFilters)) { |
||
207 | if (!$filterExpr = $event->getFilterExpression()) { |
||
208 | $this->applyFilters($standardFilters, $queryBuilder, $options['filters']); |
||
209 | } else { |
||
210 | $queryBuilder->andWhere($filterExpr); |
||
211 | } |
||
212 | } |
||
213 | |||
214 | // Build ORDER BY clause |
||
215 | if (!empty($options['order'])) { |
||
216 | if (is_array($options['order'])) { |
||
217 | if (isset($options['order']['column'])) { |
||
218 | $queryBuilder->orderBy($options['order']['column'], $options['order']['direction']); |
||
219 | } elseif (!empty($options['order'][0][1])) { |
||
220 | list($column, $dir) = $options['order']; |
||
221 | $queryBuilder->orderBy($column, $dir); |
||
222 | } else { |
||
223 | foreach ($options['order'] as $order) { |
||
224 | $queryBuilder->orderBy($order); |
||
225 | } |
||
226 | } |
||
227 | } else { |
||
228 | $queryBuilder->orderBy($options['order']); |
||
229 | } |
||
230 | } elseif ($order = $this->entity->getTableOrder()) { |
||
231 | foreach ($order as $o) { |
||
232 | if (!empty($options['columns'][$o['column']]['formula'])) { |
||
233 | $queryBuilder->orderBy($options['columns'][$o['column']]['formula'], $o['direction']); |
||
234 | } elseif (!empty($o['column'])) { |
||
235 | $queryBuilder->orderBy($o['column'], $o['direction']); |
||
236 | } |
||
237 | } |
||
238 | } |
||
239 | |||
240 | // Build GROUP BY |
||
241 | if ($groupByOptions = $this->entity->getGroupBy()) { |
||
242 | $groupByColumns = []; |
||
243 | |||
244 | foreach ($groupByOptions as $groupBy) { |
||
245 | if (isset($options['columns'][$groupBy])) { |
||
246 | $fieldOptions = $options['columns'][$groupBy]; |
||
247 | |||
248 | if (isset($fieldOptions['groupByFormula'])) { |
||
249 | $groupByColumns[] = $fieldOptions['groupByFormula']; |
||
250 | } elseif (isset($fieldOptions['formula'])) { |
||
251 | $groupByColumns[] = $fieldOptions['formula']; |
||
252 | } else { |
||
253 | $groupByColumns[] = $groupBy; |
||
254 | } |
||
255 | } |
||
256 | } |
||
257 | |||
258 | $queryBuilder->addGroupBy($groupByColumns); |
||
259 | } elseif (!empty($options['groupby']) && empty($groupByOptions)) { |
||
260 | $queryBuilder->addGroupBy($options['groupby']); |
||
261 | } |
||
262 | |||
263 | // Build LIMIT clause |
||
264 | if (!empty($options['limit'])) { |
||
265 | $queryBuilder->setFirstResult($options['start']) |
||
266 | ->setMaxResults($options['limit']); |
||
267 | } |
||
268 | |||
269 | if (!empty($options['having'])) { |
||
270 | if (is_array($options['having'])) { |
||
271 | foreach ($options['having'] as $having) { |
||
272 | $queryBuilder->andHaving($having); |
||
273 | } |
||
274 | } else { |
||
275 | $queryBuilder->having($options['having']); |
||
276 | } |
||
277 | } |
||
278 | |||
279 | $selectColumns = []; |
||
280 | |||
281 | // Build SELECT clause |
||
282 | if (!$event->getSelectColumns()) { |
||
283 | $fields = $this->entity->getColumns(); |
||
284 | $groupByColumns = $queryBuilder->getQueryPart('groupBy'); |
||
285 | $groupByColumnsKeys = array_flip($groupByColumns); |
||
286 | |||
287 | foreach ($fields as $field) { |
||
288 | if (isset($options['columns'][$field])) { |
||
289 | $fieldOptions = $options['columns'][$field]; |
||
290 | |||
291 | if (array_key_exists('channelData', $fieldOptions)) { |
||
292 | $selectText = $this->buildCaseSelect($fieldOptions['channelData']); |
||
293 | } else { |
||
294 | // If there is a group by, and this field has groupByFormula |
||
295 | if (isset($fieldOptions['groupByFormula']) && isset($groupByColumnsKeys[$fieldOptions['groupByFormula']])) { |
||
296 | $selectText = $fieldOptions['groupByFormula']; |
||
297 | } elseif (isset($fieldOptions['formula'])) { |
||
298 | $selectText = $fieldOptions['formula']; |
||
299 | } else { |
||
300 | $selectText = $this->sanitizeColumnName($field); |
||
301 | } |
||
302 | } |
||
303 | |||
304 | // support for prefix and suffix to value in query |
||
305 | $prefix = isset($fieldOptions['prefix']) ? $fieldOptions['prefix'] : ''; |
||
306 | $suffix = isset($fieldOptions['suffix']) ? $fieldOptions['suffix'] : ''; |
||
307 | if ($prefix || $suffix) { |
||
308 | $selectText = 'CONCAT(\''.$prefix.'\', '.$selectText.',\''.$suffix.'\')'; |
||
309 | } |
||
310 | |||
311 | if (isset($fieldOptions['alias'])) { |
||
312 | $selectText .= ' AS '.$fieldOptions['alias']; |
||
313 | } |
||
314 | |||
315 | $selectColumns[] = $selectText; |
||
316 | } |
||
317 | } |
||
318 | } |
||
319 | |||
320 | // Generate a count query in case a formula needs total number |
||
321 | $countQuery = clone $queryBuilder; |
||
322 | $countQuery->select('COUNT(*) as count'); |
||
323 | |||
324 | $countSql = sprintf('(%s)', $countQuery->getSQL()); |
||
325 | |||
326 | // Replace {{count}} with the count query |
||
327 | array_walk($selectColumns, function (&$columnValue, $columnIndex) use ($countSql) { |
||
328 | if (false !== strpos($columnValue, '{{count}}')) { |
||
329 | $columnValue = str_replace('{{count}}', $countSql, $columnValue); |
||
330 | } |
||
331 | }); |
||
332 | |||
333 | $queryBuilder->addSelect($selectColumns); |
||
334 | |||
335 | // Add Aggregators |
||
336 | $aggregators = $this->entity->getAggregators(); |
||
337 | $aggregatorSelect = []; |
||
338 | |||
339 | if ($aggregators && $groupByOptions) { |
||
0 ignored issues
–
show
|
|||
340 | foreach ($aggregators as $aggregator) { |
||
341 | if (isset($options['columns'][$aggregator['column']]) && isset($options['columns'][$aggregator['column']]['formula'])) { |
||
342 | $columnSelect = $options['columns'][$aggregator['column']]['formula']; |
||
343 | } else { |
||
344 | $columnSelect = $aggregator['column']; |
||
345 | } |
||
346 | |||
347 | $selectText = sprintf('%s(%s)', $aggregator['function'], $columnSelect); |
||
348 | |||
349 | if ('AVG' === $aggregator['function']) { |
||
350 | $selectText = sprintf('ROUND(%s)', $selectText); |
||
351 | } |
||
352 | |||
353 | $aggregatorSelect[] = sprintf("%s AS '%s %s'", $selectText, $aggregator['function'], $aggregator['column']); |
||
354 | } |
||
355 | |||
356 | $queryBuilder->addSelect($aggregatorSelect); |
||
357 | } |
||
358 | |||
359 | return $queryBuilder; |
||
360 | } |
||
361 | |||
362 | /** |
||
363 | * Build a CASE select statement. |
||
364 | * |
||
365 | * @param array $channelData ['channelName' => ['prefix' => XX, 'column' => 'XX.XX'] |
||
366 | * |
||
367 | * @return string |
||
368 | */ |
||
369 | private function buildCaseSelect(array $channelData) |
||
370 | { |
||
371 | $case = 'CASE'; |
||
372 | |||
373 | foreach ($channelData as $data) { |
||
374 | $case .= ' WHEN '.$data['column'].' IS NOT NULL THEN '.$data['column']; |
||
375 | } |
||
376 | |||
377 | return $case.' ELSE NULL END '; |
||
378 | } |
||
379 | |||
380 | /** |
||
381 | * @return bool |
||
382 | */ |
||
383 | private function applyFilters(array $filters, QueryBuilder $queryBuilder, array $filterDefinitions) |
||
384 | { |
||
385 | $expr = $queryBuilder->expr(); |
||
386 | $groups = []; |
||
387 | $groupExpr = $queryBuilder->expr()->andX(); |
||
388 | |||
389 | if (count($filters)) { |
||
390 | foreach ($filters as $i => $filter) { |
||
391 | $exprFunction = isset($filter['expr']) ? $filter['expr'] : $filter['condition']; |
||
392 | $paramName = sprintf('i%dc%s', $i, InputHelper::alphanum($filter['column'])); |
||
393 | |||
394 | if (array_key_exists('glue', $filter) && 'or' === $filter['glue']) { |
||
395 | if ($groupExpr->count()) { |
||
396 | $groups[] = $groupExpr; |
||
397 | $groupExpr = $queryBuilder->expr()->andX(); |
||
398 | } |
||
399 | } |
||
400 | |||
401 | switch ($exprFunction) { |
||
402 | case 'notEmpty': |
||
403 | $groupExpr->add( |
||
404 | $expr->isNotNull($filter['column']) |
||
405 | ); |
||
406 | $groupExpr->add( |
||
407 | $expr->neq($filter['column'], $expr->literal('')) |
||
408 | ); |
||
409 | break; |
||
410 | case 'empty': |
||
411 | $expression = $queryBuilder->expr()->orX( |
||
412 | $queryBuilder->expr()->isNull($filter['column']), |
||
413 | $queryBuilder->expr()->eq($filter['column'], $expr->literal('')) |
||
414 | ); |
||
415 | |||
416 | $groupExpr->add( |
||
417 | $expression |
||
418 | ); |
||
419 | break; |
||
420 | default: |
||
421 | if ('' == trim($filter['value'])) { |
||
422 | // Ignore empty |
||
423 | break; |
||
424 | } |
||
425 | |||
426 | $columnValue = ":$paramName"; |
||
427 | $type = $filterDefinitions[$filter['column']]['type']; |
||
428 | if (isset($filterDefinitions[$filter['column']]['formula'])) { |
||
429 | $filter['column'] = $filterDefinitions[$filter['column']]['formula']; |
||
430 | } |
||
431 | |||
432 | switch ($type) { |
||
433 | case 'bool': |
||
434 | case 'boolean': |
||
435 | if ((int) $filter['value'] > 1) { |
||
436 | // Ignore the "reset" value of "2" |
||
437 | break 2; |
||
438 | } |
||
439 | |||
440 | $queryBuilder->setParameter($paramName, $filter['value'], 'boolean'); |
||
441 | break; |
||
442 | |||
443 | case 'float': |
||
444 | $columnValue = (float) $filter['value']; |
||
445 | break; |
||
446 | |||
447 | case 'int': |
||
448 | case 'integer': |
||
449 | $columnValue = (int) $filter['value']; |
||
450 | break; |
||
451 | |||
452 | case 'string': |
||
453 | case 'email': |
||
454 | case 'url': |
||
455 | switch ($exprFunction) { |
||
456 | case 'startsWith': |
||
457 | $exprFunction = 'like'; |
||
458 | $filter['value'] = $filter['value'].'%'; |
||
459 | break; |
||
460 | case 'endsWith': |
||
461 | $exprFunction = 'like'; |
||
462 | $filter['value'] = '%'.$filter['value']; |
||
463 | break; |
||
464 | case 'contains': |
||
465 | $exprFunction = 'like'; |
||
466 | $filter['value'] = '%'.$filter['value'].'%'; |
||
467 | break; |
||
468 | } |
||
469 | |||
470 | $queryBuilder->setParameter($paramName, $filter['value']); |
||
471 | break; |
||
472 | |||
473 | default: |
||
474 | $queryBuilder->setParameter($paramName, $filter['value']); |
||
475 | } |
||
476 | |||
477 | $groupExpr->add( |
||
478 | $expr->{$exprFunction}($filter['column'], $columnValue) |
||
479 | ); |
||
480 | } |
||
481 | } |
||
482 | } |
||
483 | |||
484 | // Get the last of the filters |
||
485 | if ($groupExpr->count()) { |
||
486 | $groups[] = $groupExpr; |
||
487 | } |
||
488 | |||
489 | if (1 === count($groups)) { |
||
490 | // Only one andX expression |
||
491 | $filterExpr = $groups[0]; |
||
492 | } elseif (count($groups) > 1) { |
||
493 | // Sets of expressions grouped by OR |
||
494 | $orX = $queryBuilder->expr()->orX(); |
||
495 | $orX->addMultiple($groups); |
||
496 | |||
497 | // Wrap in a andX for other functions to append |
||
498 | $filterExpr = $queryBuilder->expr()->andX($orX); |
||
499 | } else { |
||
500 | $filterExpr = $groupExpr; |
||
501 | } |
||
502 | |||
503 | if ($filterExpr->count()) { |
||
504 | $queryBuilder->andWhere($filterExpr); |
||
505 | |||
506 | return true; |
||
507 | } |
||
508 | |||
509 | return false; |
||
510 | } |
||
511 | |||
512 | /** |
||
513 | * We must sanitize the table aliases as they might be auto generated. |
||
514 | * Aliases like "8e296a06" makes MySql to think it is a number. |
||
515 | * Expects param in format "table_alias.column_name". |
||
516 | */ |
||
517 | private function sanitizeColumnName(string $fullCollumnName): string |
||
518 | { |
||
519 | [$tableAlias, $columnName] = explode('.', $fullCollumnName); |
||
520 | |||
521 | return "`{$tableAlias}`.`{$columnName}`"; |
||
522 | } |
||
523 | } |
||
524 |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.