Passed
Push — main ( 3566f6...4aba17 )
by Tom
59s queued 13s
created

ResolveCollectionFactory::buildEdgesAndCursors()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 19
c 1
b 0
f 0
nc 6
nop 3
dl 0
loc 31
rs 9.6333
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\Metadata\Metadata;
11
use ApiSkeletons\Doctrine\GraphQL\Type\Entity;
12
use ApiSkeletons\Doctrine\GraphQL\Type\TypeManager;
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 Metadata $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
            $collectionMetadata = $this->entityManager->getMetadataFactory()
65
                ->getMetadataFor(
66
                    (string) $this->entityManager->getMetadataFactory()
67
                        ->getMetadataFor(ClassUtils::getRealClass($source::class))
68
                        ->getAssociationTargetClass($info->fieldName),
69
                );
70
71
            $metadataConfig = $this->metadata->getMetadataConfig();
72
            $entityClass    = ClassUtils::getRealClass($source::class);
73
74
            return $this->buildPagination(
75
                $args['pagination'] ?? [],
76
                $collection,
77
                $this->buildCriteria($args['filter'] ?? [], $collectionMetadata),
78
                $metadataConfig[$entityClass]['fields'][$info->fieldName]['filterCriteriaEventName'],
79
                $source,
80
                $args,
81
                $context,
82
                $info,
83
            );
84
        };
85
    }
86
87
    /** @param mixed[] $filter */
88
    private function buildCriteria(array $filter, ClassMetadata $collectionMetadata): Criteria
89
    {
90
        $orderBy  = [];
91
        $criteria = Criteria::create();
92
93
        foreach ($filter as $field => $filters) {
94
            foreach ($filters as $filter => $value) {
95
                switch ($filter) {
96
                    case FiltersDef::IN:
97
                    case FiltersDef::NOTIN:
98
                        $value = $this->parseArrayValue($collectionMetadata, $field, $value);
99
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
100
                        break;
101
                    case FiltersDef::ISNULL:
102
                        $criteria->andWhere($criteria->expr()->$filter($field));
103
                        break;
104
                    case FiltersDef::BETWEEN:
105
                        $value = $this->parseArrayValue($collectionMetadata, $field, $value);
106
107
                        $criteria->andWhere($criteria->expr()->gte($field, $value['from']));
108
                        $criteria->andWhere($criteria->expr()->lte($field, $value['to']));
109
                        break;
110
                    case FiltersDef::SORT:
111
                        $orderBy[$field] = $value;
112
                        break;
113
                    default:
114
                        $value = $this->parseValue($collectionMetadata, $field, $value);
115
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
116
                        break;
117
                }
118
            }
119
        }
120
121
        if (! empty($orderBy)) {
122
            $criteria->orderBy($orderBy);
123
        }
124
125
        return $criteria;
126
    }
127
128
    /**
129
     * @param mixed[] $pagination
130
     *
131
     * @return mixed[]
132
     */
133
    private function buildPagination(
134
        array $pagination,
135
        PersistentCollection $collection,
136
        Criteria $criteria,
137
        string|null $filterCriteriaEventName,
138
        mixed ...$resolve,
139
    ): array {
140
        $paginationFields = [
141
            'first' => 0,
142
            'last' => 0,
143
            'after' => 0,
144
            'before' => 0,
145
        ];
146
147
        // Pagination
148
        foreach ($pagination as $field => $value) {
149
            switch ($field) {
150
                case 'after':
151
                    $paginationFields[$field] = (int) base64_decode($value, true) + 1;
152
                    break;
153
                case 'before':
154
                    $paginationFields[$field] = (int) base64_decode($value, true);
155
                    break;
156
                default:
157
                    $paginationFields[$field] = $value;
158
                    $first                    = $value;
0 ignored issues
show
Unused Code introduced by
The assignment to $first is dead and can be removed.
Loading history...
159
                    break;
160
            }
161
        }
162
163
        $itemCount = count($collection->matching($criteria));
164
165
        $offsetAndLimit = $this->calculateOffsetAndLimit($paginationFields, $itemCount);
166
        if ($offsetAndLimit['offset']) {
167
            $criteria->setFirstResult($offsetAndLimit['offset']);
168
        }
169
170
        if ($offsetAndLimit['limit']) {
171
            $criteria->setMaxResults($offsetAndLimit['limit']);
172
        }
173
174
        /**
175
         * Fire the event dispatcher using the passed event name.
176
         */
177
        if ($filterCriteriaEventName) {
178
            $this->eventDispatcher->dispatch(
179
                new FilterCriteria(
180
                    $criteria,
181
                    $filterCriteriaEventName,
182
                    ...$resolve,
183
                ),
184
            );
185
        }
186
187
        $edgesAndCursors = $this->buildEdgesAndCursors($collection->matching($criteria), $offsetAndLimit, $itemCount);
188
189
        // Return entities
190
        return [
191
            'edges' => $edgesAndCursors['edges'],
192
            'totalCount' => $itemCount,
193
            'pageInfo' => [
194
                'endCursor' => $edgesAndCursors['cursors']['end'],
195
                'startCursor' => $edgesAndCursors['cursors']['start'],
196
                'hasNextPage' => $edgesAndCursors['cursors']['end'] !== $edgesAndCursors['cursors']['last'],
197
                'hasPreviousPage' => $edgesAndCursors['cursors']['first'] !== null
198
                    && $edgesAndCursors['cursors']['start'] !== $edgesAndCursors['cursors']['first'],
199
            ],
200
        ];
201
    }
202
203
    /**
204
     * @param array<string, int>     $offsetAndLimit
205
     * @param Collection<int, mixed> $items
206
     *
207
     * @return array<string, mixed>
208
     */
209
    protected function buildEdgesAndCursors(Collection $items, array $offsetAndLimit, int $itemCount): array
210
    {
211
        $edges   = [];
212
        $index   = 0;
213
        $cursors = [
214
            'first' => null,
215
            'last'  => base64_encode((string) 0),
216
            'start' => base64_encode((string) 0),
217
        ];
218
219
        foreach ($items as $item) {
220
            $cursors['last'] = base64_encode((string) ($index + $offsetAndLimit['offset']));
221
222
            $edges[] = [
223
                'node' => $item,
224
                'cursor' => $cursors['last'],
225
            ];
226
227
            if (! $cursors['first']) {
228
                $cursors['first'] = $cursors['last'];
229
            }
230
231
            $index++;
232
        }
233
234
        $endIndex       = $itemCount ? $itemCount - 1 : 0;
235
        $cursors['end'] = base64_encode((string) $endIndex);
236
237
        return [
238
            'cursors' => $cursors,
239
            'edges'   => $edges,
240
        ];
241
    }
242
243
    /**
244
     * @param array<string, int> $paginationFields
245
     *
246
     * @return array<string, int>
247
     */
248
    protected function calculateOffsetAndLimit(array $paginationFields, int $itemCount): array
249
    {
250
        $offset = 0;
251
252
        $limit         = $this->config->getLimit();
253
        $adjustedLimit = $paginationFields['first'] ?: $paginationFields['last'] ?: $limit;
254
255
        if ($adjustedLimit < $limit) {
256
            $limit = $adjustedLimit;
257
        }
258
259
        if ($paginationFields['after']) {
260
            $offset = $paginationFields['after'];
261
        } elseif ($paginationFields['before']) {
262
            $offset = $paginationFields['before'] - $limit;
263
        }
264
265
        if ($offset < 0) {
266
            $limit += $offset;
267
            $offset = 0;
268
        }
269
270
        if ($paginationFields['last'] && ! $paginationFields['before']) {
271
            $offset = $itemCount - $paginationFields['last'];
272
        }
273
274
        return [
275
            'offset' => $offset,
276
            'limit'  => $limit,
277
        ];
278
    }
279
}
280