ResolveCollectionFactory   A
last analyzed

Complexity

Total Complexity 37

Size/Duplication

Total Lines 263
Duplicated Lines 0 %

Importance

Changes 12
Bugs 2 Features 0
Metric Value
eloc 134
c 12
b 2
f 0
dl 0
loc 263
rs 9.44
wmc 37

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 1
A parseValue() 0 7 1
A parseArrayValue() 0 7 2
B calculateOffsetAndLimit() 0 39 11
B buildCriteria() 0 38 9
A get() 0 26 1
A buildEdgesAndCursors() 0 31 4
B buildPagination() 0 68 8
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ApiSkeletons\Doctrine\GraphQL\Resolve;
6
7
use ApiSkeletons\Doctrine\GraphQL\Config;
8
use ApiSkeletons\Doctrine\GraphQL\Criteria\Filters as FiltersDef;
9
use ApiSkeletons\Doctrine\GraphQL\Event\FilterCriteria;
10
use ApiSkeletons\Doctrine\GraphQL\Type\Entity;
11
use ApiSkeletons\Doctrine\GraphQL\Type\TypeManager;
12
use ArrayObject;
13
use Closure;
14
use Doctrine\Common\Collections\Collection;
15
use Doctrine\Common\Collections\Criteria;
16
use Doctrine\Common\Util\ClassUtils;
17
use Doctrine\ORM\EntityManager;
18
use Doctrine\ORM\Mapping\ClassMetadata;
19
use Doctrine\ORM\PersistentCollection;
20
use GraphQL\Type\Definition\ResolveInfo;
21
use League\Event\EventDispatcher;
22
23
use function base64_decode;
24
use function base64_encode;
25
use function count;
26
27
class ResolveCollectionFactory
28
{
29
    public function __construct(
30
        protected EntityManager $entityManager,
31
        protected Config $config,
32
        protected FieldResolver $fieldResolver,
33
        protected TypeManager $typeManager,
34
        protected EventDispatcher $eventDispatcher,
35
        protected ArrayObject $metadata,
36
    ) {
37
    }
38
39
    public function parseValue(ClassMetadata $metadata, string $field, mixed $value): mixed
40
    {
41
        /** @psalm-suppress UndefinedDocblockClass */
42
        $fieldMapping = $metadata->getFieldMapping($field);
43
        $graphQLType  = $this->typeManager->get($fieldMapping['type']);
44
45
        return $graphQLType->parseValue($graphQLType->serialize($value));
46
    }
47
48
    /** @param mixed[] $value */
49
    public function parseArrayValue(ClassMetadata $metadata, string $field, array $value): mixed
50
    {
51
        foreach ($value as $key => $val) {
52
            $value[$key] = $this->parseValue($metadata, $field, $val);
53
        }
54
55
        return $value;
56
    }
57
58
    public function get(Entity $entity): Closure
59
    {
60
        return function ($source, array $args, $context, ResolveInfo $info) {
61
            $fieldResolver = $this->fieldResolver;
62
            $collection    = $fieldResolver($source, $args, $context, $info);
63
64
            $entityClassName = ClassUtils::getRealClass($source::class);
65
66
            $targetClassName = (string) $this->entityManager->getMetadataFactory()
67
                ->getMetadataFor($entityClassName)
68
                ->getAssociationTargetClass($info->fieldName);
69
70
            $collectionMetadata = $this->entityManager->getMetadataFactory()
71
                ->getMetadataFor($targetClassName);
72
73
            return $this->buildPagination(
74
                $entityClassName,
75
                $targetClassName,
76
                $args['pagination'] ?? [],
77
                $collection,
78
                $this->buildCriteria($args['filter'] ?? [], $collectionMetadata),
79
                $this->metadata[$entityClassName]['fields'][$info->fieldName]['filterCriteriaEventName'],
80
                $source,
81
                $args,
82
                $context,
83
                $info,
84
            );
85
        };
86
    }
87
88
    /** @param mixed[] $filter */
89
    private function buildCriteria(array $filter, ClassMetadata $collectionMetadata): Criteria
90
    {
91
        $orderBy  = [];
92
        $criteria = Criteria::create();
93
94
        foreach ($filter as $field => $filters) {
95
            foreach ($filters as $filter => $value) {
96
                switch ($filter) {
97
                    case FiltersDef::IN:
98
                    case FiltersDef::NOTIN:
99
                        $value = $this->parseArrayValue($collectionMetadata, $field, $value);
100
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
101
                        break;
102
                    case FiltersDef::ISNULL:
103
                        $criteria->andWhere($criteria->expr()->$filter($field));
104
                        break;
105
                    case FiltersDef::BETWEEN:
106
                        $value = $this->parseArrayValue($collectionMetadata, $field, $value);
107
108
                        $criteria->andWhere($criteria->expr()->gte($field, $value['from']));
109
                        $criteria->andWhere($criteria->expr()->lte($field, $value['to']));
110
                        break;
111
                    case FiltersDef::SORT:
112
                        $orderBy[$field] = $value;
113
                        break;
114
                    default:
115
                        $value = $this->parseValue($collectionMetadata, $field, $value);
116
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
117
                        break;
118
                }
119
            }
120
        }
121
122
        if (! empty($orderBy)) {
123
            $criteria->orderBy($orderBy);
124
        }
125
126
        return $criteria;
127
    }
128
129
    /**
130
     * @param mixed[] $pagination
131
     *
132
     * @return mixed[]
133
     */
134
    private function buildPagination(
135
        string $entityClassName,
136
        string $targetClassName,
137
        array $pagination,
138
        PersistentCollection $collection,
139
        Criteria $criteria,
140
        string|null $filterCriteriaEventName,
141
        mixed ...$resolve,
142
    ): array {
143
        $paginationFields = [
144
            'first' => 0,
145
            'last' => 0,
146
            'after' => 0,
147
            'before' => 0,
148
        ];
149
150
        // Pagination
151
        foreach ($pagination as $field => $value) {
152
            switch ($field) {
153
                case 'after':
154
                    $paginationFields[$field] = (int) base64_decode($value, true) + 1;
155
                    break;
156
                case 'before':
157
                    $paginationFields[$field] = (int) base64_decode($value, true);
158
                    break;
159
                default:
160
                    $paginationFields[$field] = $value;
161
                    $first                    = $value;
0 ignored issues
show
Unused Code introduced by
The assignment to $first is dead and can be removed.
Loading history...
162
                    break;
163
            }
164
        }
165
166
        $itemCount = count($collection->matching($criteria));
167
168
        $offsetAndLimit = $this->calculateOffsetAndLimit($resolve[3]->fieldName, $entityClassName, $targetClassName, $paginationFields, $itemCount);
169
        if ($offsetAndLimit['offset']) {
170
            $criteria->setFirstResult($offsetAndLimit['offset']);
171
        }
172
173
        if ($offsetAndLimit['limit']) {
174
            $criteria->setMaxResults($offsetAndLimit['limit']);
175
        }
176
177
        /**
178
         * Fire the event dispatcher using the passed event name.
179
         */
180
        if ($filterCriteriaEventName) {
181
            $this->eventDispatcher->dispatch(
182
                new FilterCriteria(
183
                    $criteria,
184
                    $filterCriteriaEventName,
185
                    ...$resolve,
186
                ),
187
            );
188
        }
189
190
        $edgesAndCursors = $this->buildEdgesAndCursors($collection->matching($criteria), $offsetAndLimit, $itemCount);
191
192
        // Return entities
193
        return [
194
            'edges' => $edgesAndCursors['edges'],
195
            'totalCount' => $itemCount,
196
            'pageInfo' => [
197
                'endCursor' => $edgesAndCursors['cursors']['end'],
198
                'startCursor' => $edgesAndCursors['cursors']['start'],
199
                'hasNextPage' => $edgesAndCursors['cursors']['end'] !== $edgesAndCursors['cursors']['last'],
200
                'hasPreviousPage' => $edgesAndCursors['cursors']['first'] !== null
201
                    && $edgesAndCursors['cursors']['start'] !== $edgesAndCursors['cursors']['first'],
202
            ],
203
        ];
204
    }
205
206
    /**
207
     * @param array<string, int>     $offsetAndLimit
208
     * @param Collection<int, mixed> $items
209
     *
210
     * @return array<string, mixed>
211
     */
212
    protected function buildEdgesAndCursors(Collection $items, array $offsetAndLimit, int $itemCount): array
213
    {
214
        $edges   = [];
215
        $index   = 0;
216
        $cursors = [
217
            'first' => null,
218
            'last'  => base64_encode((string) 0),
219
            'start' => base64_encode((string) 0),
220
        ];
221
222
        foreach ($items as $item) {
223
            $cursors['last'] = base64_encode((string) ($index + $offsetAndLimit['offset']));
224
225
            $edges[] = [
226
                'node' => $item,
227
                'cursor' => $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
240
        return [
241
            'cursors' => $cursors,
242
            'edges'   => $edges,
243
        ];
244
    }
245
246
    /**
247
     * @param array<string, int> $paginationFields
248
     *
249
     * @return array<string, int>
250
     */
251
    protected function calculateOffsetAndLimit(string $associationName, string $entityClassName, string $targetClassName, array $paginationFields, int $itemCount): array
252
    {
253
        $offset = 0;
254
255
        $limit            = $this->metadata[$targetClassName]['limit'];
256
        $associationLimit = $this->metadata[$entityClassName]['fields'][$associationName]['limit'] ?? null;
257
258
        if ($associationLimit) {
259
            $limit = $associationLimit;
260
        }
261
262
        if (! $limit) {
263
            $limit = $this->config->getLimit();
264
        }
265
266
        $adjustedLimit = $paginationFields['first'] ?: $paginationFields['last'] ?: $limit;
267
268
        if ($adjustedLimit < $limit) {
269
            $limit = $adjustedLimit;
270
        }
271
272
        if ($paginationFields['after']) {
273
            $offset = $paginationFields['after'];
274
        } elseif ($paginationFields['before']) {
275
            $offset = $paginationFields['before'] - $limit;
276
        }
277
278
        if ($offset < 0) {
279
            $limit += $offset;
280
            $offset = 0;
281
        }
282
283
        if ($paginationFields['last'] && ! $paginationFields['before']) {
284
            $offset = $itemCount - $paginationFields['last'];
285
        }
286
287
        return [
288
            'offset' => $offset,
289
            'limit'  => $limit,
290
        ];
291
    }
292
}
293