ResolveCollectionFactory::buildCriteria()   B
last analyzed

Complexity

Conditions 7
Paths 12

Size

Total Lines 33
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 22
nc 12
nop 2
dl 0
loc 33
rs 8.6346
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ApiSkeletons\Doctrine\ORM\GraphQL\Resolve;
6
7
use ApiSkeletons\Doctrine\ORM\GraphQL\Config;
8
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Criteria as CriteriaEvent;
9
use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters;
10
use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\Entity;
11
use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity\EntityTypeContainer;
12
use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer;
13
use ArrayObject;
14
use Closure;
15
use Doctrine\Common\Collections\Collection;
16
use Doctrine\Common\Collections\Criteria;
17
use Doctrine\ORM\EntityManager;
18
use Doctrine\ORM\PersistentCollection;
19
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
20
use GraphQL\Type\Definition\ResolveInfo;
21
use League\Event\EventDispatcher;
22
23
use function array_flip;
24
use function base64_decode;
25
use function base64_encode;
26
use function count;
27
use function in_array;
28
29
/**
30
 * Build a resolver for collections
31
 */
32
class ResolveCollectionFactory
33
{
34
    public function __construct(
35
        protected readonly EntityManager $entityManager,
36
        protected readonly Config $config,
37
        protected readonly FieldResolver $fieldResolver,
38
        protected readonly TypeContainer $typeContainer,
39
        protected readonly EntityTypeContainer $entityTypeContainer,
40
        protected readonly EventDispatcher $eventDispatcher,
41
        protected readonly ArrayObject $metadata,
42
    ) {
43
    }
44
45
    public function get(Entity $entity): Closure
46
    {
47
        return function ($source, array $args, $context, ResolveInfo $info) use ($entity) {
48
            $fieldResolver = $this->fieldResolver;
49
            $collection    = $fieldResolver($source, $args, $context, $info);
50
51
            $defaultProxyClassNameResolver = new DefaultProxyClassNameResolver();
52
            $entityClassName               = $defaultProxyClassNameResolver->getClass($source);
53
54
            // If an alias map exists, check for an alias
55
            $targetCollectionName = $info->fieldName;
56
            if (in_array($info->fieldName, $this->entityTypeContainer->get($entityClassName)->getExtractionMap())) {
57
                $targetCollectionName = array_flip($this->entityTypeContainer
58
                    ->get($entityClassName)->getExtractionMap())[$info->fieldName] ?? $info->fieldName;
59
            }
60
61
            $targetClassName = (string) $this->entityManager->getMetadataFactory()
62
                ->getMetadataFor($entityClassName)
63
                ->getAssociationTargetClass($targetCollectionName);
64
65
            return $this->buildPagination(
66
                $entityClassName,
67
                $targetClassName,
68
                $args['pagination'] ?? [],
69
                $collection,
70
                $this->buildCriteria($args['filter'] ?? [], $entity),
71
                $this->metadata[$entityClassName]['fields'][$targetCollectionName]['criteriaEventName'],
72
                $source,
73
                $args,
74
                $context,
75
                $info,
76
            );
77
        };
78
    }
79
80
    /** @param mixed[] $filter */
81
    protected function buildCriteria(array $filter, Entity $entity): Criteria
82
    {
83
        $orderBy  = [];
84
        $criteria = Criteria::create();
85
86
        foreach ($filter as $field => $filters) {
87
            // Resolve aliases
88
            $field = array_flip($entity->getExtractionMap())[$field] ?? $field;
89
90
            foreach ($filters as $filter => $value) {
91
                switch (Filters::from($filter)) {
92
                    case Filters::ISNULL:
93
                        $criteria->andWhere($criteria->expr()->$filter($field));
94
                        break;
95
                    case Filters::BETWEEN:
96
                        $criteria->andWhere($criteria->expr()->gte($field, $value['from']));
97
                        $criteria->andWhere($criteria->expr()->lte($field, $value['to']));
98
                        break;
99
                    case Filters::SORT:
100
                        $orderBy[$field] = $value;
101
                        break;
102
                    default:
103
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
104
                        break;
105
                }
106
            }
107
        }
108
109
        if (! empty($orderBy)) {
110
            $criteria->orderBy($orderBy);
111
        }
112
113
        return $criteria;
114
    }
115
116
    /**
117
     * @param mixed[] $pagination
118
     *
119
     * @return mixed[]
120
     */
121
    protected function buildPagination(
122
        string $entityClassName,
123
        string $targetClassName,
124
        array $pagination,
125
        PersistentCollection $collection,
126
        Criteria $criteria,
127
        string|null $criteriaEventName,
128
        mixed ...$resolve,
129
    ): array {
130
        $paginationFields = [
131
            'first' => 0,
132
            'last' => 0,
133
            'after' => 0,
134
            'before' => 0,
135
        ];
136
137
        // Pagination
138
        foreach ($pagination as $field => $value) {
139
            $paginationFields[$field] = $value;
140
141
            if ($field === 'after') {
142
                $paginationFields[$field] = (int) base64_decode($value, true) + 1;
143
            }
144
145
            if ($field !== 'before') {
146
                continue;
147
            }
148
149
            $paginationFields[$field] = (int) base64_decode($value, true);
150
        }
151
152
        // Calculate offset and limit
153
        $itemCount      = count($collection->matching($criteria));
154
        $offsetAndLimit = $this->calculateOffsetAndLimit($resolve[3]->fieldName, $entityClassName, $targetClassName, $paginationFields, $itemCount);
155
156
        /**
157
         * Fire the event dispatcher using the passed event name.
158
         */
159
        if ($criteriaEventName) {
160
            $event = new CriteriaEvent(
161
                $criteriaEventName,
162
                $criteria,
163
                $collection,
164
                $offsetAndLimit['offset'],
165
                $offsetAndLimit['limit'],
166
                ...$resolve,
167
            );
168
169
            $this->eventDispatcher->dispatch($event);
170
            $collection = $event->getCollection();
171
        }
172
173
        // Recalculate offset and limit after Criteria event
174
        $itemCount      = count($collection->matching($criteria));
0 ignored issues
show
Bug introduced by
The method matching() does not exist on Doctrine\Common\Collections\Collection. It seems like you code against a sub-type of said class. However, the method does not exist in Doctrine\Common\Collections\AbstractLazyCollection. Are you sure you never get one of those? ( Ignorable by Annotation )

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

174
        $itemCount      = count($collection->/** @scrutinizer ignore-call */ matching($criteria));
Loading history...
175
        $offsetAndLimit = $this->calculateOffsetAndLimit($resolve[3]->fieldName, $entityClassName, $targetClassName, $paginationFields, $itemCount);
176
177
        // Add offset and limit after Criteria event
178
        if ($offsetAndLimit['offset']) {
179
            $criteria->setFirstResult($offsetAndLimit['offset']);
180
        }
181
182
        if ($offsetAndLimit['limit']) {
183
            $criteria->setMaxResults($offsetAndLimit['limit']);
184
        }
185
186
        $edgesAndCursors = $this->buildEdgesAndCursors($collection->matching($criteria), $offsetAndLimit, $itemCount);
187
188
        // Return entities
189
        return [
190
            'edges' => $edgesAndCursors['edges'],
191
            'totalCount' => $itemCount,
192
            'pageInfo' => [
193
                'endCursor' => $edgesAndCursors['cursors']['last'],
194
                'startCursor' => $edgesAndCursors['cursors']['start'],
195
                'hasNextPage' => $edgesAndCursors['cursors']['end'] !== $edgesAndCursors['cursors']['last'],
196
                'hasPreviousPage' => $edgesAndCursors['cursors']['start'] !== base64_encode((string) 0),
197
            ],
198
        ];
199
    }
200
201
    /**
202
     * @param array<string, int>     $offsetAndLimit
203
     * @param Collection<int, mixed> $items
204
     *
205
     * @return array<string, mixed>
206
     */
207
    protected function buildEdgesAndCursors(Collection $items, array $offsetAndLimit, int $itemCount): array
208
    {
209
        $edges   = [];
210
        $index   = 0;
211
        $cursors = [
212
            'first' => null,
213
            'last'  => base64_encode((string) 0),
214
            'start' => base64_encode((string) 0),
215
        ];
216
217
        $startCursor = null;
218
        foreach ($items as $item) {
219
            $cursors['last'] = base64_encode((string) ($index + $offsetAndLimit['offset']));
220
221
            $edges[] = [
222
                'node' => $item,
223
                'cursor' => $cursors['last'],
224
            ];
225
226
            if (! $startCursor) {
227
                $startCursor = $cursors['last'];
228
            }
229
230
            if (! $cursors['first']) {
231
                $cursors['first'] = $cursors['last'];
232
            }
233
234
            $index++;
235
        }
236
237
        $endIndex         = $itemCount ? $itemCount - 1 : 0;
238
        $cursors['end']   = base64_encode((string) $endIndex);
239
        $cursors['start'] = $startCursor ?? $cursors['start'];
240
241
        return [
242
            'cursors' => $cursors,
243
            'edges'   => $edges,
244
        ];
245
    }
246
247
    /**
248
     * @param array<string, int> $paginationFields
249
     *
250
     * @return array<string, int>
251
     */
252
    protected function calculateOffsetAndLimit(
253
        string $associationName,
254
        string $entityClassName,
255
        string $targetClassName,
256
        array $paginationFields,
257
        int $itemCount,
258
    ): array {
259
        $offset = 0;
260
261
        $limit            = $this->metadata[$targetClassName]['limit'];
262
        $associationLimit = $this->metadata[$entityClassName]['fields'][$associationName]['limit'] ?? null;
263
264
        if ($associationLimit) {
265
            $limit = $associationLimit;
266
        }
267
268
        if (! $limit) {
269
            $limit = $this->config->getLimit();
270
        }
271
272
        $adjustedLimit = $paginationFields['first'] ?: $paginationFields['last'] ?: $limit;
273
274
        if ($adjustedLimit < $limit) {
275
            $limit = $adjustedLimit;
276
        }
277
278
        if ($paginationFields['after']) {
279
            $offset = $paginationFields['after'];
280
        } elseif ($paginationFields['before']) {
281
            $offset = $paginationFields['before'] - $limit;
282
        }
283
284
        if ($offset < 0) {
285
            $limit += $offset;
286
            $offset = 0;
287
        }
288
289
        if ($paginationFields['last'] && ! $paginationFields['before']) {
290
            $offset = $itemCount - $paginationFields['last'];
291
        }
292
293
        return [
294
            'offset' => $offset,
295
            'limit'  => $limit,
296
        ];
297
    }
298
}
299