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