Passed
Push — master ( 559e1b...b73725 )
by Jan
04:19
created

CustomORMAdapter::configureOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 22
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 22
rs 9.7333
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
7
 *
8
 * Copyright (C) 2019 Jan Böhmer (https://github.com/jbtronics)
9
 *
10
 * This program is free software; you can redistribute it and/or
11
 * modify it under the terms of the GNU General Public License
12
 * as published by the Free Software Foundation; either version 2
13
 * of the License, or (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program; if not, write to the Free Software
22
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
23
 */
24
25
namespace App\DataTables\Adapter;
26
27
use Doctrine\Common\Persistence\ManagerRegistry;
28
use Doctrine\ORM\EntityManager;
29
use Doctrine\ORM\Query;
30
use Doctrine\ORM\QueryBuilder;
31
use Doctrine\ORM\Tools\Pagination\Paginator;
32
use Omines\DataTablesBundle\Adapter\AbstractAdapter;
33
use Omines\DataTablesBundle\Adapter\AdapterQuery;
34
use Omines\DataTablesBundle\Adapter\Doctrine\Event\ORMAdapterQueryEvent;
35
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\AutomaticQueryBuilder;
36
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\QueryBuilderProcessorInterface;
37
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
38
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapterEvents;
39
use Omines\DataTablesBundle\Column\AbstractColumn;
40
use Omines\DataTablesBundle\DataTableState;
41
use Omines\DataTablesBundle\Exception\InvalidConfigurationException;
42
use Omines\DataTablesBundle\Exception\MissingDependencyException;
43
use Symfony\Component\OptionsResolver\Options;
44
use Symfony\Component\OptionsResolver\OptionsResolver;
45
use Traversable;
46
47
/**
48
 * Override default ORM Adapter, to allow fetch joins (allow addSelect with ManyToOne Collections).
49
 * This should improves performance for Part Tables.
50
 * Based on: https://github.com/omines/datatables-bundle/blob/master/tests/Fixtures/AppBundle/DataTable/Adapter/CustomORMAdapter.php.
51
 */
52
class CustomORMAdapter extends AbstractAdapter
53
{
54
    /** @var ManagerRegistry */
55
    private $registry;
56
57
    /** @var EntityManager */
58
    private $manager;
59
60
    /** @var \Doctrine\ORM\Mapping\ClassMetadata */
61
    private $metadata;
62
63
    /** @var int */
64
    private $hydrationMode;
65
66
    /** @var QueryBuilderProcessorInterface[] */
67
    private $queryBuilderProcessors;
68
69
    /** @var QueryBuilderProcessorInterface[] */
70
    protected $criteriaProcessors;
71
72
    /** @var bool */
73
    protected $allow_fetch_join;
74
75
76
    /**
77
     * DoctrineAdapter constructor.
78
     */
79
    public function __construct(ManagerRegistry $registry = null)
80
    {
81
        if (null === $registry) {
82
            throw new MissingDependencyException('Install doctrine/doctrine-bundle to use the ORMAdapter');
83
        }
84
85
        parent::__construct();
86
        $this->registry = $registry;
87
    }
88
89
    /**
90
     * {@inheritdoc}
91
     */
92
    public function configure(array $options)
93
    {
94
        $resolver = new OptionsResolver();
95
        $this->configureOptions($resolver);
96
        $options = $resolver->resolve($options);
97
98
        // Enable automated mode or just get the general default entity manager
99
        if (null === ($this->manager = $this->registry->getManagerForClass($options['entity']))) {
100
            throw new InvalidConfigurationException(sprintf('Doctrine has no manager for entity "%s", is it correctly imported and referenced?', $options['entity']));
101
        }
102
        $this->metadata = $this->manager->getClassMetadata($options['entity']);
103
        if (empty($options['query'])) {
104
            $options['query'] = [new AutomaticQueryBuilder($this->manager, $this->metadata)];
105
        }
106
107
        // Set options
108
        $this->hydrationMode = $options['hydrate'];
109
        $this->queryBuilderProcessors = $options['query'];
110
        $this->criteriaProcessors = $options['criteria'];
111
        $this->allow_fetch_join = $options['allow_fetch_join'];
112
    }
113
114
    /**
115
     * @param mixed $processor
116
     */
117
    public function addCriteriaProcessor($processor)
118
    {
119
        $this->criteriaProcessors[] = $this->normalizeProcessor($processor);
120
    }
121
122
    protected function prepareQuery(AdapterQuery $query)
123
    {
124
        $state = $query->getState();
125
        $query->set('qb', $builder = $this->createQueryBuilder($state));
126
        $query->set('rootAlias', $rootAlias = $builder->getDQLPart('from')[0]->getAlias());
127
128
        // Provide default field mappings if needed
129
        foreach ($state->getDataTable()->getColumns() as $column) {
130
            if (null === $column->getField() && isset($this->metadata->fieldMappings[$name = $column->getName()])) {
131
                $column->setOption('field', "{$rootAlias}.{$name}");
132
            }
133
        }
134
135
        /** @var Query\Expr\From $fromClause */
136
        $fromClause = $builder->getDQLPart('from')[0];
137
        $identifier = "{$fromClause->getAlias()}.{$this->metadata->getSingleIdentifierFieldName()}";
138
139
140
        $query->setTotalRows($this->getCount($builder, $identifier, true));
141
142
        // Get record count after filtering
143
        $this->buildCriteria($builder, $state);
144
        $query->setFilteredRows($this->getCount($builder, $identifier, false));
145
146
        // Perform mapping of all referred fields and implied fields
147
        $aliases = $this->getAliases($query);
148
        $query->set('aliases', $aliases);
149
        $query->setIdentifierPropertyPath($this->mapFieldToPropertyPath($identifier, $aliases));
150
    }
151
152
    /**
153
     * @return array
154
     */
155
    protected function getAliases(AdapterQuery $query)
156
    {
157
        /** @var QueryBuilder $builder */
158
        $builder = $query->get('qb');
159
        $aliases = [];
160
161
        /** @var Query\Expr\From $from */
162
        foreach ($builder->getDQLPart('from') as $from) {
163
            $aliases[$from->getAlias()] = [null, $this->manager->getMetadataFactory()->getMetadataFor($from->getFrom())];
164
        }
165
166
        // Alias all joins
167
        foreach ($builder->getDQLPart('join') as $joins) {
168
            /** @var Query\Expr\Join $join */
169
            foreach ($joins as $join) {
170
                if (false === mb_strstr($join->getJoin(), '.')) {
171
                    continue;
172
                }
173
174
                list($origin, $target) = explode('.', $join->getJoin());
175
176
                $mapping = $aliases[$origin][1]->getAssociationMapping($target);
177
                $aliases[$join->getAlias()] = [$join->getJoin(), $this->manager->getMetadataFactory()->getMetadataFor($mapping['targetEntity'])];
178
            }
179
        }
180
181
        return $aliases;
182
    }
183
184
    /**
185
     * {@inheritdoc}
186
     */
187
    protected function mapPropertyPath(AdapterQuery $query, AbstractColumn $column)
188
    {
189
        return $this->mapFieldToPropertyPath($column->getField(), $query->get('aliases'));
0 ignored issues
show
Bug introduced by
It seems like $query->get('aliases') can also be of type null; however, parameter $aliases of App\DataTables\Adapter\C...apFieldToPropertyPath() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

189
        return $this->mapFieldToPropertyPath($column->getField(), /** @scrutinizer ignore-type */ $query->get('aliases'));
Loading history...
190
    }
191
192
    protected function getResults(AdapterQuery $query): \Traversable
193
    {
194
        /** @var QueryBuilder $builder */
195
        $builder = $query->get('qb');
196
        $state = $query->getState();
197
198
        // Apply definitive view state for current 'page' of the table
199
        foreach ($state->getOrderBy() as list($column, $direction)) {
200
            /** @var AbstractColumn $column */
201
            if ($column->isOrderable()) {
202
                $builder->addOrderBy($column->getOrderField(), $direction);
203
            }
204
        }
205
        if ($state->getLength() > 0) {
206
            $builder
207
                ->setFirstResult($state->getStart())
208
                ->setMaxResults($state->getLength());
209
        }
210
211
        $query = $builder->getQuery();
212
        $event = new ORMAdapterQueryEvent($query);
213
        $state->getDataTable()->getEventDispatcher()->dispatch($event, ORMAdapterEvents::PRE_QUERY);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with Omines\DataTablesBundle\...dapterEvents::PRE_QUERY. ( Ignorable by Annotation )

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

213
        $state->getDataTable()->getEventDispatcher()->/** @scrutinizer ignore-call */ dispatch($event, ORMAdapterEvents::PRE_QUERY);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
214
215
        if ($this->allow_fetch_join && $this->hydrationMode === Query::HYDRATE_OBJECT) {
216
            $paginator = new Paginator($query);
217
            $iterator = $paginator->getIterator();
218
        } else {
219
            $iterator = $query->iterate([], $this->hydrationMode);
220
        }
221
222
        foreach ($iterator as $result) {
223
            if (Query::HYDRATE_OBJECT === $this->hydrationMode) {
224
                yield $entity = $result;
225
                $this->manager->detach($entity);
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\ORM\EntityManager::detach() has been deprecated: 2.7 This method is being removed from the ORM and won't have any replacement ( Ignorable by Annotation )

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

225
                /** @scrutinizer ignore-deprecated */ $this->manager->detach($entity);

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

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

Loading history...
226
            } else {
227
                yield $entity = array_values($result)[0];
0 ignored issues
show
Unused Code introduced by
The assignment to $entity is dead and can be removed.
Loading history...
228
            }
229
        }
230
    }
231
232
    protected function buildCriteria(QueryBuilder $queryBuilder, DataTableState $state)
233
    {
234
        foreach ($this->criteriaProcessors as $provider) {
235
            $provider->process($queryBuilder, $state);
236
        }
237
    }
238
239
    protected function createQueryBuilder(DataTableState $state): QueryBuilder
240
    {
241
        /** @var QueryBuilder $queryBuilder */
242
        $queryBuilder = $this->manager->createQueryBuilder();
243
244
        // Run all query builder processors in order
245
        foreach ($this->queryBuilderProcessors as $processor) {
246
            $processor->process($queryBuilder, $state);
247
        }
248
249
        return $queryBuilder;
250
    }
251
252
    /**
253
     * @param $identifier
254
     * @return int
255
     */
256
    protected function getCount(QueryBuilder $queryBuilder, $identifier, $total_count = false)
257
    {
258
        if ($this->allow_fetch_join) {
259
            /** The paginator count queries can be rather slow, so when query for total count (100ms or longer),
260
             * just return the entity count.
261
             */
262
            if ($total_count) {
263
                /** @var Query\Expr\From $from_expr */
264
                $from_expr = $queryBuilder->getDQLPart('from')[0];
265
                return $this->manager->getRepository($from_expr->getFrom())->count([]);
266
            }
267
268
            $paginator = new Paginator($queryBuilder);
269
            return $paginator->count();
270
        }
271
272
        $qb = clone $queryBuilder;
273
274
        $qb->resetDQLPart('orderBy');
275
        $gb = $qb->getDQLPart('groupBy');
276
        if (empty($gb) || !$this->hasGroupByPart($identifier, $gb)) {
277
            $qb->select($qb->expr()->count($identifier));
278
279
            return (int) $qb->getQuery()->getSingleScalarResult();
280
        } else {
281
            $qb->resetDQLPart('groupBy');
282
            $qb->select($qb->expr()->countDistinct($identifier));
283
284
            return (int) $qb->getQuery()->getSingleScalarResult();
285
        }
286
    }
287
288
    /**
289
     * @param $identifier
290
     * @param Query\Expr\GroupBy[] $gbList
291
     * @return bool
292
     */
293
    protected function hasGroupByPart($identifier, array $gbList)
294
    {
295
        foreach ($gbList as $gb) {
296
            if (in_array($identifier, $gb->getParts(), true)) {
297
                return true;
298
            }
299
        }
300
301
        return false;
302
    }
303
304
    /**
305
     * @param string $field
306
     * @return string
307
     */
308
    private function mapFieldToPropertyPath($field, array $aliases = [])
309
    {
310
        $parts = explode('.', $field);
311
        if (count($parts) < 2) {
312
            throw new InvalidConfigurationException(sprintf("Field name '%s' must consist at least of an alias and a field separated with a period", $field));
313
        }
314
        list($origin, $target) = $parts;
315
316
        $path = [$target];
317
        $current = $aliases[$origin][0];
318
319
        while (null !== $current) {
320
            list($origin, $target) = explode('.', $current);
321
            $path[] = $target;
322
            $current = $aliases[$origin][0];
323
        }
324
325
        if (Query::HYDRATE_ARRAY === $this->hydrationMode) {
326
            return '[' . implode('][', array_reverse($path)) . ']';
327
        } else {
328
            return implode('.', array_reverse($path));
329
        }
330
    }
331
332
    protected function configureOptions(OptionsResolver $resolver)
333
    {
334
        $providerNormalizer = function (Options $options, $value) {
335
            return array_map([$this, 'normalizeProcessor'], (array) $value);
336
        };
337
338
        $resolver
339
            ->setDefaults([
340
                              'hydrate' => Query::HYDRATE_OBJECT,
341
                              'allow_fetch_join' => false,
342
                              'query' => [],
343
                              'criteria' => function (Options $options) {
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

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

343
                              'criteria' => function (/** @scrutinizer ignore-unused */ Options $options) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
344
                                  return [new SearchCriteriaProvider()];
345
                              },
346
                          ])
347
            ->setRequired('entity')
348
            ->setAllowedTypes('entity', ['string'])
349
            ->setAllowedTypes('hydrate', 'int')
350
            ->setAllowedTypes('query', [QueryBuilderProcessorInterface::class, 'array', 'callable'])
351
            ->setAllowedTypes('criteria', [QueryBuilderProcessorInterface::class, 'array', 'callable', 'null'])
352
            ->setNormalizer('query', $providerNormalizer)
353
            ->setNormalizer('criteria', $providerNormalizer)
354
        ;
355
    }
356
357
    /**
358
     * @param callable|QueryBuilderProcessorInterface $provider
359
     * @return QueryBuilderProcessorInterface
360
     */
361
    private function normalizeProcessor($provider)
362
    {
363
        if ($provider instanceof QueryBuilderProcessorInterface) {
364
            return $provider;
365
        } elseif (is_callable($provider)) {
366
            return new class($provider) implements QueryBuilderProcessorInterface {
367
                private $callable;
368
369
                public function __construct(callable $value)
370
                {
371
                    $this->callable = $value;
372
                }
373
374
                public function process(QueryBuilder $queryBuilder, DataTableState $state)
375
                {
376
                    return call_user_func($this->callable, $queryBuilder, $state);
377
                }
378
            };
379
        }
380
381
        throw new InvalidConfigurationException('Provider must be a callable or implement QueryBuilderProcessorInterface');
382
    }
383
}
384