Query   B
last analyzed

Complexity

Total Complexity 44

Size/Duplication

Total Lines 295
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 1
Metric Value
wmc 44
eloc 118
dl 0
loc 295
rs 8.8798
c 4
b 1
f 1

18 Methods

Rating   Name   Duplication   Size   Complexity  
A mergeSimpleFilters() 0 22 5
A resetOrderBy() 0 5 1
A groupFilters() 0 13 3
A buildLimit() 0 11 3
A __construct() 0 8 2
A addOrderBy() 0 11 2
A getFilters() 0 3 1
A execute() 0 14 2
A buildOrderBy() 0 7 2
A addFilter() 0 6 1
A getLimit() 0 3 1
B buildWhere() 0 40 11
A limit() 0 14 3
A buildQueryBuilder() 0 16 1
A checkFilter() 0 4 2
A getSupportedFilters() 0 3 1
A count() 0 20 2
A getOrderBy() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Query often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Query, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace DH\Auditor\Provider\Doctrine\Persistence\Reader;
6
7
use DateTimeImmutable;
8
use DateTimeZone;
9
use DH\Auditor\Exception\InvalidArgumentException;
10
use DH\Auditor\Model\Entry;
11
use DH\Auditor\Provider\Doctrine\Persistence\Helper\SchemaHelper;
12
use DH\Auditor\Provider\Doctrine\Persistence\Reader\Filter\DateRangeFilter;
13
use DH\Auditor\Provider\Doctrine\Persistence\Reader\Filter\FilterInterface;
14
use DH\Auditor\Provider\Doctrine\Persistence\Reader\Filter\RangeFilter;
15
use DH\Auditor\Provider\Doctrine\Persistence\Reader\Filter\SimpleFilter;
16
use Doctrine\DBAL\Connection;
17
use Doctrine\DBAL\Query\QueryBuilder;
18
use Doctrine\DBAL\Result;
19
use Exception;
20
21
/**
22
 * @see \DH\Auditor\Tests\Provider\Doctrine\Persistence\Reader\QueryTest
23
 */
24
final class Query
25
{
26
    /**
27
     * @var string
28
     */
29
    public const TYPE = 'type';
30
31
    /**
32
     * @var string
33
     */
34
    public const CREATED_AT = 'created_at';
35
36
    /**
37
     * @var string
38
     */
39
    public const TRANSACTION_HASH = 'transaction_hash';
40
41
    /**
42
     * @var string
43
     */
44
    public const OBJECT_ID = 'object_id';
45
46
    /**
47
     * @var string
48
     */
49
    public const USER_ID = 'blame_id';
50
51
    /**
52
     * @var string
53
     */
54
    public const ID = 'id';
55
56
    /**
57
     * @var string
58
     */
59
    public const DISCRIMINATOR = 'discriminator';
60
61
    private array $filters = [];
62
63
    private array $orderBy = [];
64
65
    private Connection $connection;
66
67
    private string $table;
68
69
    private int $offset = 0;
70
71
    private int $limit = 0;
72
73
    private DateTimeZone $timezone;
74
75
    public function __construct(string $table, Connection $connection, string $timezone)
76
    {
77
        $this->connection = $connection;
78
        $this->table = $table;
79
        $this->timezone = new DateTimeZone($timezone);
80
81
        foreach ($this->getSupportedFilters() as $filterType) {
82
            $this->filters[$filterType] = [];
83
        }
84
    }
85
86
    /**
87
     * @return array<Entry>
88
     */
89
    public function execute(): array
90
    {
91
        $queryBuilder = $this->buildQueryBuilder();
92
        $statement = $queryBuilder->executeQuery();
93
94
        $result = [];
95
        \assert($statement instanceof Result);
96
        foreach ($statement->fetchAllAssociative() as $row) {
97
            \assert(\is_string($row['created_at']));
98
            $row['created_at'] = new DateTimeImmutable($row['created_at'], $this->timezone);
99
            $result[] = Entry::fromArray($row);
100
        }
101
102
        return $result;
103
    }
104
105
    public function count(): int
106
    {
107
        $queryBuilder = $this->buildQueryBuilder();
108
109
        try {
110
            $queryBuilder
111
                ->resetQueryPart('select')
112
                ->resetQueryPart('orderBy')
113
                ->setMaxResults(null)
114
                ->setFirstResult(0)
115
                ->select('COUNT(id)')
116
            ;
117
118
            /** @var false|int $result */
119
            $result = $queryBuilder->executeQuery()->fetchOne();
120
        } catch (Exception) {
121
            $result = false;
122
        }
123
124
        return (int) $result;
125
    }
126
127
    public function addFilter(FilterInterface $filter): self
128
    {
129
        $this->checkFilter($filter->getName());
130
        $this->filters[$filter->getName()][] = $filter;
131
132
        return $this;
133
    }
134
135
    public function addOrderBy(string $field, string $direction = 'DESC'): self
136
    {
137
        $this->checkFilter($field);
138
139
        if (!\in_array($direction, ['ASC', 'DESC'], true)) {
140
            throw new InvalidArgumentException('Invalid sort direction, allowed value: ASC, DESC');
141
        }
142
143
        $this->orderBy[$field] = $direction;
144
145
        return $this;
146
    }
147
148
    public function resetOrderBy(): self
149
    {
150
        $this->orderBy = [];
151
152
        return $this;
153
    }
154
155
    public function limit(int $limit, int $offset = 0): self
156
    {
157
        if (0 > $limit) {
158
            throw new InvalidArgumentException('Limit cannot be negative.');
159
        }
160
161
        if (0 > $offset) {
162
            throw new InvalidArgumentException('Offset cannot be negative.');
163
        }
164
165
        $this->limit = $limit;
166
        $this->offset = $offset;
167
168
        return $this;
169
    }
170
171
    public function getSupportedFilters(): array
172
    {
173
        return array_keys(SchemaHelper::getAuditTableIndices('fake'));
174
    }
175
176
    public function getFilters(): array
177
    {
178
        return $this->filters;
179
    }
180
181
    public function getOrderBy(): array
182
    {
183
        return $this->orderBy;
184
    }
185
186
    /**
187
     * @return array<int>
188
     */
189
    public function getLimit(): array
190
    {
191
        return [$this->limit, $this->offset];
192
    }
193
194
    private function buildQueryBuilder(): QueryBuilder
195
    {
196
        $queryBuilder = $this->connection->createQueryBuilder();
197
        $queryBuilder
198
            ->select('*')
199
            ->from($this->table, 'at')
200
        ;
201
202
        // build WHERE clause(s)
203
        $queryBuilder = $this->buildWhere($queryBuilder);
204
205
        // build ORDER BY part
206
        $queryBuilder = $this->buildOrderBy($queryBuilder);
207
208
        // build LIMIT part
209
        return $this->buildLimit($queryBuilder);
210
    }
211
212
    private function groupFilters(array $filters): array
213
    {
214
        $grouped = [];
215
216
        foreach ($filters as $filter) {
217
            if (!isset($grouped[$filter::class])) {
218
                $grouped[$filter::class] = [];
219
            }
220
221
            $grouped[$filter::class][] = $filter;
222
        }
223
224
        return $grouped;
225
    }
226
227
    private function mergeSimpleFilters(array $filters): SimpleFilter
228
    {
229
        if ([] === $filters) {
230
            throw new InvalidArgumentException('$filters cannot be empty.');
231
        }
232
233
        $merged = [];
234
        $name = null;
235
236
        foreach ($filters as $filter) {
237
            if (null === $name) {
238
                $name = $filter->getName();
239
            }
240
241
            if (\is_array($filter->getValue())) {
242
                $merged = array_merge($merged, $filter->getValue());
243
            } else {
244
                $merged[] = $filter->getValue();
245
            }
246
        }
247
248
        return new SimpleFilter($name, $merged);
249
    }
250
251
    private function buildWhere(QueryBuilder $queryBuilder): QueryBuilder
252
    {
253
        foreach ($this->filters as $rawFilters) {
254
            if (0 === (is_countable($rawFilters) ? \count($rawFilters) : 0)) {
255
                continue;
256
            }
257
258
            // group filters by class
259
            $grouped = $this->groupFilters($rawFilters);
260
261
            foreach ($grouped as $class => $filters) {
262
                switch ($class) {
263
                    case SimpleFilter::class:
264
                        $filters = [$this->mergeSimpleFilters($filters)];
265
266
                        break;
267
268
                    case RangeFilter::class:
269
                    case DateRangeFilter::class:
270
                        break;
271
                }
272
273
                foreach ($filters as $filter) {
274
                    $data = $filter->getSQL();
275
276
                    $queryBuilder->andWhere($data['sql']);
277
278
                    foreach ($data['params'] as $name => $value) {
279
                        if (\is_array($value)) {
280
                            // TODO: replace Connection::PARAM_STR_ARRAY with ArrayParameterType::STRING when dropping support of doctrine/dbal < 3.6
281
                            $queryBuilder->setParameter($name, $value, Connection::PARAM_STR_ARRAY); // @phpstan-ignore-line
0 ignored issues
show
Deprecated Code introduced by
The constant Doctrine\DBAL\Connection::PARAM_STR_ARRAY has been deprecated: Use {@see ArrayParameterType::STRING} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

281
                            $queryBuilder->setParameter($name, $value, /** @scrutinizer ignore-deprecated */ Connection::PARAM_STR_ARRAY); // @phpstan-ignore-line

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
282
                        } else {
283
                            $queryBuilder->setParameter($name, $value);
284
                        }
285
                    }
286
                }
287
            }
288
        }
289
290
        return $queryBuilder;
291
    }
292
293
    private function buildOrderBy(QueryBuilder $queryBuilder): QueryBuilder
294
    {
295
        foreach ($this->orderBy as $field => $direction) {
296
            $queryBuilder->addOrderBy($field, $direction);
297
        }
298
299
        return $queryBuilder;
300
    }
301
302
    private function buildLimit(QueryBuilder $queryBuilder): QueryBuilder
303
    {
304
        if (0 < $this->limit) {
305
            $queryBuilder->setMaxResults($this->limit);
306
        }
307
308
        if (0 < $this->offset) {
309
            $queryBuilder->setFirstResult($this->offset);
310
        }
311
312
        return $queryBuilder;
313
    }
314
315
    private function checkFilter(string $filter): void
316
    {
317
        if (!\in_array($filter, $this->getSupportedFilters(), true)) {
318
            throw new InvalidArgumentException(sprintf('Unsupported "%s" filter, allowed filters: %s.', $filter, implode(', ', $this->getSupportedFilters())));
319
        }
320
    }
321
}
322