Issues (3627)

ReportBundle/Builder/MauticReportBuilder.php (1 issue)

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
Bug Best Practice introduced by
The expression $aggregators of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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.

Loading history...
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